From 5a1fc7d0179a85febc05832162100473aea3346d Mon Sep 17 00:00:00 2001 From: Leo Nash Date: Wed, 3 Dec 2025 06:47:21 +0000 Subject: [PATCH 001/135] Update CHANGELOG for 0.2 Discourage people from running Knots with LDK --- CHANGELOG.md | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e14753538be..a431ecfab60 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -40,11 +40,13 @@ pre-signed transactions, relying on anchor bumps instead. They also utilize the new TRUC + ephemeral dust policy in Bitcoin Core 29 to substantially improve the lightning security model. This requires having a path of Bitcoin - Core 29+ nodes between you and a miner for transactions to be mined. This - only works with LDK peers, and feature signaling may change in a future - version of LDK, breaking compatibility. This is negotiated automatically for - manually-accepted inbound channels and negotiated for outbound channels based - on `ChannelHandshakeConfig::negotiate_anchor_zero_fee_commitments`. + Core 29+ nodes between you and a miner for transactions to be mined. Bitcoin + Knots blocks these transactions by default, and is not recommended for use + with a lightning node. 0FC channels currently only work with LDK peers, and + feature signaling may change in a future version of LDK, breaking + compatibility. This is negotiated automatically for manually-accepted inbound + channels and negotiated for outbound channels based on + `ChannelHandshakeConfig::negotiate_anchor_zero_fee_commitments`. * `Event::BumpTransaction` is now always generated even if the transaction has sufficient fee. This allows you to manage transaction broadcasting more granularly for anchor channels (#4001). From 79a9ac08afd8b6c22b54a33280211afa40f70474 Mon Sep 17 00:00:00 2001 From: Matt Corallo Date: Wed, 3 Dec 2025 20:24:31 +0000 Subject: [PATCH 002/135] Note `SocketDescriptor::send_data` semantics changes in renotes `SocketDescriptor::send_data` semantics were changed in 0.2, without changing the method signature. As such the release notes really should be explicit about this. --- CHANGELOG.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a431ecfab60..a51c5fda8bd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -146,7 +146,9 @@ `ListProtocols` message (#3785). * A rare race which might lead `PeerManager` (and `lightning-net-tokio`) to stop reading from a peer until a new message is sent to that peer has been - fixed (#4168). + fixed. Note that this changed the semantics of the + `SocketDescriptor::send_data` method without changing its signature, check + that your implementation matches the new documentation (#4168). * The fields in `SocketAddress::OnionV3` are now correctly parsed, and the `Display` for such addresses is now lowercase (#4090). * `PeerManager` is now more conservative about disconnecting peers which aren't From 54791561ff3ef28adb3e768543db2966d8055bd3 Mon Sep 17 00:00:00 2001 From: Leo Nash Date: Fri, 5 Dec 2025 22:55:26 +0000 Subject: [PATCH 003/135] Assert peer supports splicing before splicing channel Backport of cdba25fa8797ee39e69e0276b28fe7473dac5f19 --- lightning/src/ln/channelmanager.rs | 13 +++++- lightning/src/ln/splicing_tests.rs | 67 +++++++++++++++++++++++++++++- 2 files changed, 77 insertions(+), 3 deletions(-) diff --git a/lightning/src/ln/channelmanager.rs b/lightning/src/ln/channelmanager.rs index 82a45dcb1a6..822b06ac9c4 100644 --- a/lightning/src/ln/channelmanager.rs +++ b/lightning/src/ln/channelmanager.rs @@ -4767,8 +4767,17 @@ where Err(e) => return Err(e), }; - let mut peer_state_lock = peer_state_mutex.lock().unwrap(); - let peer_state = &mut *peer_state_lock; + 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) { diff --git a/lightning/src/ln/splicing_tests.rs b/lightning/src/ln/splicing_tests.rs index e387ac3bfdd..d8c71c0ea1a 100644 --- a/lightning/src/ln/splicing_tests.rs +++ b/lightning/src/ln/splicing_tests.rs @@ -17,7 +17,9 @@ use crate::events::bump_transaction::sync::WalletSourceSync; use crate::events::{ClosureReason, Event, FundingInfo, HTLCHandlingFailureType}; use crate::ln::chan_utils; use crate::ln::channel::CHANNEL_ANNOUNCEMENT_PROPAGATION_DELAY; -use crate::ln::channelmanager::{PaymentId, RecipientOnionFields, BREAKDOWN_TIMEOUT}; +use crate::ln::channelmanager::{ + provided_init_features, PaymentId, RecipientOnionFields, BREAKDOWN_TIMEOUT, +}; use crate::ln::functional_test_utils::*; use crate::ln::funding::{FundingTxInput, SpliceContribution}; use crate::ln::msgs::{self, BaseMessageHandler, ChannelMessageHandler, MessageSendEvent}; @@ -30,6 +32,69 @@ use crate::util::test_channel_signer::SignerOp; use bitcoin::secp256k1::PublicKey; use bitcoin::{Amount, OutPoint as BitcoinOutPoint, ScriptBuf, Transaction, TxOut}; +#[test] +fn test_splicing_not_supported_api_error() { + let chanmon_cfgs = create_chanmon_cfgs(2); + let node_cfgs = create_node_cfgs(2, &chanmon_cfgs); + let mut features = provided_init_features(&test_default_channel_config()); + features.clear_splicing(); + *node_cfgs[0].override_init_features.borrow_mut() = Some(features); + 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(&nodes, 0, 1); + + let bs_contribution = SpliceContribution::SpliceIn { + value: Amount::ZERO, + inputs: Vec::new(), + change_script: None, + }; + + let res = nodes[1].node.splice_channel( + &channel_id, + &node_id_0, + bs_contribution.clone(), + 0, // funding_feerate_per_kw, + None, // locktime + ); + match res { + Err(APIError::ChannelUnavailable { err }) => { + assert!(err.contains("Peer does not support splicing")) + }, + _ => panic!("Wrong error {:?}", res.err().unwrap()), + } + + nodes[0].node.peer_disconnected(node_id_1); + nodes[1].node.peer_disconnected(node_id_0); + + let mut features = nodes[0].node.init_features(); + features.set_splicing_optional(); + features.clear_quiescence(); + *nodes[0].override_init_features.borrow_mut() = Some(features); + + 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 res = nodes[1].node.splice_channel( + &channel_id, + &node_id_0, + bs_contribution, + 0, // funding_feerate_per_kw, + None, // locktime + ); + match res { + Err(APIError::ChannelUnavailable { err }) => { + assert!(err.contains("Peer does not support quiescence, a splicing prerequisite")) + }, + _ => panic!("Wrong error {:?}", res.err().unwrap()), + } +} + #[test] fn test_v1_splice_in_negative_insufficient_inputs() { let chanmon_cfgs = create_chanmon_cfgs(2); From 5dba661b547dde834dfc77a0293e292eaedb1c22 Mon Sep 17 00:00:00 2001 From: Matt Corallo Date: Tue, 9 Dec 2025 01:15:56 +0000 Subject: [PATCH 004/135] Make `AttributionData` actually pub since its used in the public API `AttributionData` is a part of the public `UpdateFulfillHTLC` and `UpdateFailHTLC` messages, but its not actually `pub`. Yet again re-exports bite us and leave us with a broken public API - we ended up accidentally sealing `AttributionData`. Instead, here, we just make `onion_utils` `pub` so that we avoid making the same mistake in the future. Note that this still leaves us with arather useless public `AttributionData` API - it can't be created, updated, or decoded, it can only be serialized and deserialized, but at least it exists. Backport of bd578235fbe5ed8ec18eb0ccd5e2e8fe10732ce4 Conflicts resolved in: * lightning/src/ln/onion_utils.rs semver-breaking `pub use` removal dropped in: * lightning/src/ln/mod.rs --- fuzz/src/process_onion_failure.rs | 13 +- lightning/src/events/mod.rs | 3 +- lightning/src/ln/mod.rs | 10 +- lightning/src/ln/msgs.rs | 9 +- lightning/src/ln/onion_utils.rs | 202 ++++++++++++++++-------------- 5 files changed, 120 insertions(+), 117 deletions(-) diff --git a/fuzz/src/process_onion_failure.rs b/fuzz/src/process_onion_failure.rs index 1bc9900718a..ac70562c006 100644 --- a/fuzz/src/process_onion_failure.rs +++ b/fuzz/src/process_onion_failure.rs @@ -9,10 +9,12 @@ use lightning::{ ln::{ channelmanager::{HTLCSource, PaymentId}, msgs::OnionErrorPacket, + onion_utils, }, routing::router::{BlindedTail, Path, RouteHop, TrampolineHop}, types::features::{ChannelFeatures, NodeFeatures}, util::logger::Logger, + util::ser::Readable, }; // Imports that need to be added manually @@ -126,19 +128,18 @@ fn do_test(data: &[u8], out: Out) { let failure_data = get_slice!(failure_len); let attribution_data = if get_bool!() { - Some(lightning::ln::AttributionData { - hold_times: get_slice!(80).try_into().unwrap(), - hmacs: get_slice!(840).try_into().unwrap(), - }) + let mut bytes = get_slice!(80 + 840); + let data: onion_utils::AttributionData = Readable::read(&mut bytes).unwrap(); + Some(data) } else { None }; let encrypted_packet = OnionErrorPacket { data: failure_data.into(), attribution_data: attribution_data.clone() }; - lightning::ln::process_onion_failure(&secp_ctx, &logger, &htlc_source, encrypted_packet); + onion_utils::process_onion_failure(&secp_ctx, &logger, &htlc_source, encrypted_packet); if let Some(attribution_data) = attribution_data { - lightning::ln::decode_fulfill_attribution_data( + onion_utils::decode_fulfill_attribution_data( &secp_ctx, &logger, &path, diff --git a/lightning/src/events/mod.rs b/lightning/src/events/mod.rs index b9c4b1ca1ef..d97ae6097b6 100644 --- a/lightning/src/events/mod.rs +++ b/lightning/src/events/mod.rs @@ -25,8 +25,9 @@ use crate::blinded_path::payment::{ use crate::chain::transaction; use crate::ln::channel::FUNDING_CONF_DEADLINE_BLOCKS; use crate::ln::channelmanager::{InterceptId, PaymentId, RecipientOnionFields}; +use crate::ln::msgs; +use crate::ln::onion_utils::LocalHTLCFailureReason; use crate::ln::types::ChannelId; -use crate::ln::{msgs, LocalHTLCFailureReason}; use crate::offers::invoice::Bolt12Invoice; use crate::offers::invoice_request::InvoiceRequest; use crate::offers::static_invoice::StaticInvoice; diff --git a/lightning/src/ln/mod.rs b/lightning/src/ln/mod.rs index 9473142cfed..2e6b279965f 100644 --- a/lightning/src/ln/mod.rs +++ b/lightning/src/ln/mod.rs @@ -41,7 +41,7 @@ pub mod channel; #[cfg(not(fuzzing))] pub(crate) mod channel; -pub(crate) mod onion_utils; +pub mod onion_utils; mod outbound_payment; pub mod wire; @@ -53,14 +53,6 @@ pub use onion_utils::{create_payment_onion, LocalHTLCFailureReason}; // without the node parameter being mut. This is incorrect, and thus newer rustcs will complain // about an unnecessary mut. Thus, we silence the unused_mut warning in two test modules below. -#[cfg(fuzzing)] -pub use onion_utils::decode_fulfill_attribution_data; -#[cfg(fuzzing)] -pub use onion_utils::process_onion_failure; - -#[cfg(fuzzing)] -pub use onion_utils::AttributionData; - #[cfg(test)] #[allow(unused_mut)] mod async_payments_tests; diff --git a/lightning/src/ln/msgs.rs b/lightning/src/ln/msgs.rs index 354273f7170..81fa2fc41fa 100644 --- a/lightning/src/ln/msgs.rs +++ b/lightning/src/ln/msgs.rs @@ -4357,7 +4357,7 @@ mod tests { InboundOnionForwardPayload, InboundOnionReceivePayload, OutboundTrampolinePayload, TrampolineOnionPacket, }; - use crate::ln::onion_utils::{AttributionData, HMAC_COUNT, HMAC_LEN, HOLD_TIME_LEN, MAX_HOPS}; + use crate::ln::onion_utils::AttributionData; use crate::ln::types::ChannelId; use crate::routing::gossip::{NodeAlias, NodeId}; use crate::types::features::{ @@ -5890,13 +5890,10 @@ mod tests { channel_id: ChannelId::from_bytes([2; 32]), htlc_id: 2316138423780173, reason: [1; 32].to_vec(), - attribution_data: Some(AttributionData { - hold_times: [3; MAX_HOPS * HOLD_TIME_LEN], - hmacs: [3; HMAC_LEN * HMAC_COUNT], - }), + attribution_data: Some(AttributionData::new()), }; let encoded_value = update_fail_htlc.encode(); - let target_value = >::from_hex("020202020202020202020202020202020202020202020202020202020202020200083a840000034d0020010101010101010101010101010101010101010101010101010101010101010101fd03980303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303").unwrap(); + let target_value = >::from_hex("020202020202020202020202020202020202020202020202020202020202020200083a840000034d0020010101010101010101010101010101010101010101010101010101010101010101fd03980000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000").unwrap(); assert_eq!(encoded_value, target_value); } diff --git a/lightning/src/ln/onion_utils.rs b/lightning/src/ln/onion_utils.rs index 6bba2b59e10..735ac4012ab 100644 --- a/lightning/src/ln/onion_utils.rs +++ b/lightning/src/ln/onion_utils.rs @@ -7,6 +7,8 @@ // You may not use this file except in accordance with one or both of these // licenses. +//! Low-level onion manipulation logic and fields + use super::msgs::OnionErrorPacket; use crate::blinded_path::BlindedHop; use crate::crypto::chacha20::ChaCha20; @@ -979,49 +981,102 @@ mod fuzzy_onion_utils { #[cfg(test)] pub(crate) attribution_failed_channel: Option, } + + pub fn process_onion_failure( + secp_ctx: &Secp256k1, logger: &L, htlc_source: &HTLCSource, + encrypted_packet: OnionErrorPacket, + ) -> DecodedOnionFailure + where + L::Target: Logger, + { + let (path, primary_session_priv) = match htlc_source { + HTLCSource::OutboundRoute { ref path, ref session_priv, .. } => (path, session_priv), + _ => unreachable!(), + }; + + if path.has_trampoline_hops() { + // If we have Trampoline hops, the outer onion session_priv is a hash of the inner one. + let session_priv_hash = + Sha256::hash(&primary_session_priv.secret_bytes()).to_byte_array(); + let outer_session_priv = + SecretKey::from_slice(&session_priv_hash[..]).expect("You broke SHA-256!"); + process_onion_failure_inner( + secp_ctx, + logger, + path, + &outer_session_priv, + Some(primary_session_priv), + encrypted_packet, + ) + } else { + process_onion_failure_inner( + secp_ctx, + logger, + path, + primary_session_priv, + None, + encrypted_packet, + ) + } + } + + /// Decodes the attribution data that we got back from upstream on a payment we sent. + pub fn decode_fulfill_attribution_data( + secp_ctx: &Secp256k1, logger: &L, path: &Path, outer_session_priv: &SecretKey, + mut attribution_data: AttributionData, + ) -> Vec + where + L::Target: Logger, + { + let mut hold_times = Vec::new(); + + // Only consider hops in the regular path for attribution data. Blinded path attribution data isn't accessible. + let shared_secrets = + construct_onion_keys_generic(secp_ctx, &path.hops, None, outer_session_priv) + .map(|(shared_secret, _, _, _, _)| shared_secret); + + // Path length can reach 27 hops, but attribution data can only be conveyed back to the sender from the first 20 + // hops. Determine the number of hops to be used for attribution data. + let attributable_hop_count = usize::min(path.hops.len(), MAX_HOPS); + + for (route_hop_idx, shared_secret) in + shared_secrets.enumerate().take(attributable_hop_count) + { + attribution_data.crypt(shared_secret.as_ref()); + + // Calculate position relative to the last attributable hop. The last attributable hop is at position 0. We need + // to look at the chain of HMACs that does include all data up to the last attributable hop. Hold times beyond + // the last attributable hop will not be available. + let position = attributable_hop_count - route_hop_idx - 1; + let res = attribution_data.verify(&Vec::new(), shared_secret.as_ref(), position); + match res { + Ok(hold_time) => { + hold_times.push(hold_time); + + // Shift attribution data to prepare for processing the next hop. + attribution_data.shift_left(); + }, + Err(()) => { + // We will hit this if there is a node on the path that does not support fulfill attribution data. + log_debug!( + logger, + "Invalid fulfill HMAC in attribution data for node at pos {}", + route_hop_idx + ); + + break; + }, + } + } + + hold_times + } } #[cfg(fuzzing)] pub use self::fuzzy_onion_utils::*; #[cfg(not(fuzzing))] pub(crate) use self::fuzzy_onion_utils::*; -pub fn process_onion_failure( - secp_ctx: &Secp256k1, logger: &L, htlc_source: &HTLCSource, - encrypted_packet: OnionErrorPacket, -) -> DecodedOnionFailure -where - L::Target: Logger, -{ - let (path, primary_session_priv) = match htlc_source { - HTLCSource::OutboundRoute { ref path, ref session_priv, .. } => (path, session_priv), - _ => unreachable!(), - }; - - if path.has_trampoline_hops() { - // If we have Trampoline hops, the outer onion session_priv is a hash of the inner one. - let session_priv_hash = Sha256::hash(&primary_session_priv.secret_bytes()).to_byte_array(); - let outer_session_priv = - SecretKey::from_slice(&session_priv_hash[..]).expect("You broke SHA-256!"); - process_onion_failure_inner( - secp_ctx, - logger, - path, - &outer_session_priv, - Some(primary_session_priv), - encrypted_packet, - ) - } else { - process_onion_failure_inner( - secp_ctx, - logger, - path, - primary_session_priv, - None, - encrypted_packet, - ) - } -} - /// Process failure we got back from upstream on a payment we sent (implying htlc_source is an /// OutboundRoute). fn process_onion_failure_inner( @@ -1466,56 +1521,6 @@ where } } -/// Decodes the attribution data that we got back from upstream on a payment we sent. -pub fn decode_fulfill_attribution_data( - secp_ctx: &Secp256k1, logger: &L, path: &Path, outer_session_priv: &SecretKey, - mut attribution_data: AttributionData, -) -> Vec -where - L::Target: Logger, -{ - let mut hold_times = Vec::new(); - - // Only consider hops in the regular path for attribution data. Blinded path attribution data isn't accessible. - let shared_secrets = - construct_onion_keys_generic(secp_ctx, &path.hops, None, outer_session_priv) - .map(|(shared_secret, _, _, _, _)| shared_secret); - - // Path length can reach 27 hops, but attribution data can only be conveyed back to the sender from the first 20 - // hops. Determine the number of hops to be used for attribution data. - let attributable_hop_count = usize::min(path.hops.len(), MAX_HOPS); - - for (route_hop_idx, shared_secret) in shared_secrets.enumerate().take(attributable_hop_count) { - attribution_data.crypt(shared_secret.as_ref()); - - // Calculate position relative to the last attributable hop. The last attributable hop is at position 0. We need - // to look at the chain of HMACs that does include all data up to the last attributable hop. Hold times beyond - // the last attributable hop will not be available. - let position = attributable_hop_count - route_hop_idx - 1; - let res = attribution_data.verify(&Vec::new(), shared_secret.as_ref(), position); - match res { - Ok(hold_time) => { - hold_times.push(hold_time); - - // Shift attribution data to prepare for processing the next hop. - attribution_data.shift_left(); - }, - Err(()) => { - // We will hit this if there is a node on the path that does not support fulfill attribution data. - log_debug!( - logger, - "Invalid fulfill HMAC in attribution data for node at pos {}", - route_hop_idx - ); - - break; - }, - } - } - - hold_times -} - const BADONION: u16 = 0x8000; const PERM: u16 = 0x4000; const NODE: u16 = 0x2000; @@ -2520,6 +2525,7 @@ where } /// 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 fn create_payment_onion( secp_ctx: &Secp256k1, path: &Path, session_priv: &SecretKey, total_msat: u64, @@ -2708,22 +2714,28 @@ fn decode_next_hop, N: NextPacketBytes>( } } -pub const HOLD_TIME_LEN: usize = 4; -pub const MAX_HOPS: usize = 20; -pub const HMAC_LEN: usize = 4; +pub(crate) const HOLD_TIME_LEN: usize = 4; +pub(crate) const MAX_HOPS: usize = 20; +pub(crate) const HMAC_LEN: usize = 4; // Define the number of HMACs in the attributable data block. For the first node, there are 20 HMACs, and then for every // subsequent node, the number of HMACs decreases by 1. 20 + 19 + 18 + ... + 1 = 20 * 21 / 2 = 210. -pub const HMAC_COUNT: usize = MAX_HOPS * (MAX_HOPS + 1) / 2; +pub(crate) const HMAC_COUNT: usize = MAX_HOPS * (MAX_HOPS + 1) / 2; #[derive(Clone, Debug, Hash, PartialEq, Eq)] +/// Attribution data allows the sender of an HTLC to identify which hop failed an HTLC robustly, +/// preventing earlier hops from corrupting the HTLC failure information (or at least allowing the +/// sender to identify the earliest hop which corrupted HTLC failure information). +/// +/// Additionally, it allows a sender to identify how long each hop along a path held an HTLC, with +/// 100ms granularity. pub struct AttributionData { - pub hold_times: [u8; MAX_HOPS * HOLD_TIME_LEN], - pub hmacs: [u8; HMAC_LEN * HMAC_COUNT], + hold_times: [u8; MAX_HOPS * HOLD_TIME_LEN], + hmacs: [u8; HMAC_LEN * HMAC_COUNT], } impl AttributionData { - pub fn new() -> Self { + pub(crate) fn new() -> Self { Self { hold_times: [0; MAX_HOPS * HOLD_TIME_LEN], hmacs: [0; HMAC_LEN * HMAC_COUNT] } } } @@ -2772,7 +2784,7 @@ impl AttributionData { /// Writes the HMACs corresponding to the given position that have been added already by downstream hops. Position is /// relative to the final node. The final node is at position 0. - pub fn write_downstream_hmacs(&self, position: usize, w: &mut HmacEngine) { + pub(crate) fn write_downstream_hmacs(&self, position: usize, w: &mut HmacEngine) { // Set the index to the first downstream HMAC that we need to include. Note that we skip the first MAX_HOPS HMACs // because this is space reserved for the HMACs that we are producing for the current node. let mut hmac_idx = MAX_HOPS + MAX_HOPS - position - 1; From d6b10e0c7b923c7a73f1fc52d98bf5d4ff630826 Mon Sep 17 00:00:00 2001 From: Leo Nash Date: Wed, 10 Dec 2025 20:43:02 +0000 Subject: [PATCH 005/135] Clarify splicing feature flag requirements Backport of 6578b88dafe08c507a58809876dc907969eeafa1 --- lightning/src/ln/channelmanager.rs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/lightning/src/ln/channelmanager.rs b/lightning/src/ln/channelmanager.rs index 822b06ac9c4..9cf17663185 100644 --- a/lightning/src/ln/channelmanager.rs +++ b/lightning/src/ln/channelmanager.rs @@ -4707,6 +4707,12 @@ where /// the channel. This will spend the channel's funding transaction output, effectively replacing /// it with a new one. /// + /// # Required Feature Flags + /// + /// Initiating a splice requires that the channel counterparty supports splicing. Any + /// channel (no matter the type) can be spliced, as long as the counterparty is currently + /// connected. + /// /// # Arguments /// /// Provide a `contribution` to determine if value is spliced in or out. The splice initiator is From 6e7aae739cb7cf582943eab4e6e3e15ec20e3990 Mon Sep 17 00:00:00 2001 From: Matt Corallo Date: Mon, 26 Jan 2026 13:10:42 +0000 Subject: [PATCH 006/135] Make `fail_payment_along_path` pub The next backport commit requires this and it was done upstream in 173481f6e77cd1c91e9bc6fa3ff2771d31413a4d, which we partially backport here. --- lightning/src/ln/functional_test_utils.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lightning/src/ln/functional_test_utils.rs b/lightning/src/ln/functional_test_utils.rs index 7af632e0351..484e4f2c774 100644 --- a/lightning/src/ln/functional_test_utils.rs +++ b/lightning/src/ln/functional_test_utils.rs @@ -3486,7 +3486,7 @@ pub fn send_along_route_with_secret<'a, 'b, 'c>( payment_id } -fn fail_payment_along_path<'a, 'b, 'c>(expected_path: &[&Node<'a, 'b, 'c>]) { +pub fn fail_payment_along_path<'a, 'b, 'c>(expected_path: &[&Node<'a, 'b, 'c>]) { let origin_node_id = expected_path[0].node.get_our_node_id(); // iterate from the receiving node to the origin node and handle update fail htlc. From 7132d811859df8ae944bcb25e34ee9ffc4a37f1d Mon Sep 17 00:00:00 2001 From: Matt Corallo Date: Wed, 14 Jan 2026 01:26:21 +0000 Subject: [PATCH 007/135] Remove spurious debug assertion added in 0.2 In 20877b3e229ffedee9483e2b021fdcb98c7a378a we added a `debug_assert`ion to validate that if we call `maybe_free_holding_cell_htlcs` and it doesn't manage to generate a new commitment (implying `!can_generate_new_commitment()`) that we don't have any HTLCs to fail, but there was no reason for that, and its reachable. Here we simply remove the spurious debug assertion and add a test that exercises it. Backport of b524b9bbbcbddac8a2896fe929611bc0ad1e23a4 --- lightning/src/ln/channel.rs | 1 - lightning/src/ln/functional_test_utils.rs | 4 +- lightning/src/ln/functional_tests.rs | 151 ++++++++++++++++++++++ 3 files changed, 153 insertions(+), 3 deletions(-) diff --git a/lightning/src/ln/channel.rs b/lightning/src/ln/channel.rs index e47b5492efd..289a241bc43 100644 --- a/lightning/src/ln/channel.rs +++ b/lightning/src/ln/channel.rs @@ -8876,7 +8876,6 @@ where update_fail_htlcs.len() + update_fail_malformed_htlcs.len(), &self.context.channel_id); } else { - debug_assert!(htlcs_to_fail.is_empty()); let reason = if self.context.channel_state.is_local_stfu_sent() { "exits quiescence" } else if self.context.channel_state.is_monitor_update_in_progress() { diff --git a/lightning/src/ln/functional_test_utils.rs b/lightning/src/ln/functional_test_utils.rs index 484e4f2c774..bd4403fd3fe 100644 --- a/lightning/src/ln/functional_test_utils.rs +++ b/lightning/src/ln/functional_test_utils.rs @@ -971,7 +971,7 @@ pub fn get_revoke_commit_msgs>( assert_eq!(node_id, recipient); (*msg).clone() }, - _ => panic!("Unexpected event"), + _ => panic!("Unexpected event: {events:?}"), }, match events[1] { MessageSendEvent::UpdateHTLCs { ref node_id, ref channel_id, ref updates } => { @@ -984,7 +984,7 @@ pub fn get_revoke_commit_msgs>( assert!(updates.commitment_signed.iter().all(|cs| cs.channel_id == *channel_id)); updates.commitment_signed.clone() }, - _ => panic!("Unexpected event"), + _ => panic!("Unexpected event: {events:?}"), }, ) } diff --git a/lightning/src/ln/functional_tests.rs b/lightning/src/ln/functional_tests.rs index c161a9664c0..36fb17ff076 100644 --- a/lightning/src/ln/functional_tests.rs +++ b/lightning/src/ln/functional_tests.rs @@ -9848,3 +9848,154 @@ pub fn test_multi_post_event_actions() { do_test_multi_post_event_actions(true); do_test_multi_post_event_actions(false); } + +#[xtest(feature = "_externalize_tests")] +pub fn test_dust_exposure_holding_cell_assertion() { + // Test that we properly move forward if we pop an HTLC-add from the holding cell but fail to + // add it to the channel. In 0.2 this cause a (harmless in prod) debug assertion failure. We + // try to ensure that this won't happen by checking that an HTLC will be able to be added + // before we add it to the holding cell, so getting into this state takes a bit of work. + // + // Here we accomplish this by using the dust exposure limit. This has the unique feature that + // node C can increase node B's dust exposure on the B <-> C channel without B doing anything. + // To exploit this, we get node B one HTLC away from being over-exposed to dust, give it one + // more HTLC in the holding cell, then have node C add an HTLC. By the time the holding-cell + // HTLC is released we are at max-dust-exposure and will fail it. + + let chanmon_cfgs = create_chanmon_cfgs(3); + let node_cfgs = create_node_cfgs(3, &chanmon_cfgs); + + // Configure nodes with specific dust limits + let mut config = test_default_channel_config(); + // 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; + + let configs = [Some(config.clone()), Some(config.clone()), Some(config.clone())]; + let node_chanmgrs = create_node_chanmgrs(3, &node_cfgs, &configs); + let 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 channels: A <-> B <-> C + create_announced_chan_between_nodes(&nodes, 0, 1); + let bc_chan_id = create_announced_chan_between_nodes(&nodes, 1, 2).2; + send_payment(&nodes[0], &[&nodes[1], &nodes[2]], 10_000_000); + + // Send multiple dust HTLCs from B to C to approach the dust limit (including transaction fees) + for _ in 0..4 { + route_payment(&nodes[1], &[&nodes[2]], DUST_HTLC_VALUE_MSAT); + } + + // At this point we shouldn't be over the dust limit, and should still be able to send HTLCs. + let bs_chans = nodes[1].node.list_channels(); + let bc_chan = bs_chans.iter().find(|chan| chan.counterparty.node_id == node_c_id).unwrap(); + assert_eq!( + bc_chan.next_outbound_htlc_minimum_msat, + config.channel_handshake_config.our_htlc_minimum_msat + ); + + // Add a further HTLC from B to C, but don't deliver the send messages. + // After this we'll only have the ability to add one more HTLC, but by not delivering the send + // 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 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); + let send_bc = SendEvent::from_node(&nodes[1]); + + let bs_chans = nodes[1].node.list_channels(); + let bc_chan = bs_chans.iter().find(|chan| chan.counterparty.node_id == node_c_id).unwrap(); + assert_eq!( + bc_chan.next_outbound_htlc_minimum_msat, + config.channel_handshake_config.our_htlc_minimum_msat + ); + + // Forward an additional HTLC from A through B to C. This will go in B's holding cell for node + // C as it is waiting on a response to the above messages. + let payment_params_ac = PaymentParameters::from_node_id(node_c_id, TEST_FINAL_CLTV) + .with_bolt11_features(nodes[2].node.bolt11_invoice_features()) + .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 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); + + let send_ab = SendEvent::from_node(&nodes[0]); + nodes[1].node.handle_update_add_htlc(node_a_id, &send_ab.msgs[0]); + do_commitment_signed_dance(&nodes[1], &nodes[0], &send_ab.commitment_msg, false, true); + + // At this point when we process pending forwards the HTLC will go into the holding cell and no + // further messages will be generated. Node B will also be at its maximum dust exposure and + // will refuse to send any dust HTLCs (when it includes the holding cell HTLC). + expect_and_process_pending_htlcs(&nodes[1], false); + assert!(nodes[1].node.get_and_clear_pending_msg_events().is_empty()); + + let bs_chans = nodes[1].node.list_channels(); + let bc_chan = bs_chans.iter().find(|chan| chan.counterparty.node_id == node_c_id).unwrap(); + assert!(bc_chan.next_outbound_htlc_minimum_msat > DUST_HTLC_VALUE_MSAT); + + // Send an additional HTLC from C to B. This will make B unable to forward the HTLC already in + // 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 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); + + // Now deliver all the messages and make sure that the HTLC is failed-back. + let send_event_cb = SendEvent::from_node(&nodes[2]); + nodes[1].node.handle_update_add_htlc(node_c_id, &send_event_cb.msgs[0]); + nodes[1].node.handle_commitment_signed_batch_test(node_c_id, &send_event_cb.commitment_msg); + check_added_monitors(&nodes[1], 1); + + nodes[2].node.handle_update_add_htlc(node_b_id, &send_bc.msgs[0]); + nodes[2].node.handle_commitment_signed_batch_test(node_b_id, &send_bc.commitment_msg); + check_added_monitors(&nodes[2], 1); + + let cs_raa = get_event_msg!(nodes[2], MessageSendEvent::SendRevokeAndACK, node_b_id); + nodes[1].node.handle_revoke_and_ack(node_c_id, &cs_raa); + check_added_monitors(&nodes[1], 1); + let (bs_raa, bs_cs) = get_revoke_commit_msgs(&nodes[1], &node_c_id); + + // When we delivered the RAA above, we attempted (and failed) to add the HTLC to the channel, + // causing it to be ready to fail-back, which we do here: + let next_hop = + HTLCHandlingFailureType::Forward { node_id: Some(node_c_id), channel_id: bc_chan_id }; + expect_htlc_forwarding_fails(&nodes[1], &[next_hop]); + check_added_monitors(&nodes[1], 1); + fail_payment_along_path(&[&nodes[0], &nodes[1]]); + let conditions = PaymentFailedConditions::new(); + expect_payment_failed_conditions(&nodes[0], payment_hash_cell, false, conditions); + + nodes[2].node.handle_revoke_and_ack(node_b_id, &bs_raa); + check_added_monitors(&nodes[2], 1); + let cs_cs = get_htlc_update_msgs(&nodes[2], &node_b_id); + + nodes[2].node.handle_commitment_signed_batch_test(node_b_id, &bs_cs); + check_added_monitors(&nodes[2], 1); + let cs_raa = get_event_msg!(nodes[2], MessageSendEvent::SendRevokeAndACK, node_b_id); + + nodes[1].node.handle_commitment_signed_batch_test(node_c_id, &cs_cs.commitment_signed); + check_added_monitors(&nodes[1], 1); + let bs_raa = get_event_msg!(nodes[1], MessageSendEvent::SendRevokeAndACK, node_c_id); + + nodes[1].node.handle_revoke_and_ack(node_c_id, &cs_raa); + check_added_monitors(&nodes[1], 1); + expect_and_process_pending_htlcs(&nodes[1], false); + expect_payment_claimable!(nodes[1], payment_hash_cb, payment_secret_cb, DUST_HTLC_VALUE_MSAT); + + nodes[2].node.handle_revoke_and_ack(node_b_id, &bs_raa); + check_added_monitors(&nodes[2], 1); + + // Now that everything has settled, make sure the channels still work with a simple claim. + claim_payment(&nodes[2], &[&nodes[1]], payment_preimage_cb); +} From a22e62aacc5554fdd2fe2fc8fe0b5cf0fdd6de62 Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Thu, 4 Dec 2025 09:40:23 +0100 Subject: [PATCH 008/135] Bias `Selector` to first poll the sleeper future Previously, `lightning-background-processor`'s `Selector` would poll all other futures *before* finally polling the sleeper and returning the `exit` flag if it's ready. This could lead to scenarios where we infinitely keep processing background events and never respect the `exit` flag, as long as any of other futures keep being ready. Here, we instead bias the `Selector` to always *first* poll the sleeper future, and hence have us act on the `exit` flag immediately if is set. Backport of 9c0ca26e4afb55013fdfab780f7db7e8f2ce1180 --- lightning-background-processor/src/lib.rs | 36 ++++++++++++----------- 1 file changed, 19 insertions(+), 17 deletions(-) diff --git a/lightning-background-processor/src/lib.rs b/lightning-background-processor/src/lib.rs index 19333c5823a..31e519b9f57 100644 --- a/lightning-background-processor/src/lib.rs +++ b/lightning-background-processor/src/lib.rs @@ -476,11 +476,11 @@ pub(crate) mod futures_util { use core::pin::Pin; use core::task::{Poll, RawWaker, RawWakerVTable, Waker}; pub(crate) struct Selector< - A: Future + Unpin, + A: Future + Unpin, B: Future + Unpin, C: Future + Unpin, D: Future + Unpin, - E: Future + Unpin, + E: Future + Unpin, > { pub a: A, pub b: B, @@ -490,28 +490,30 @@ pub(crate) mod futures_util { } pub(crate) enum SelectorOutput { - A, + A(bool), B, C, D, - E(bool), + E, } impl< - A: Future + Unpin, + A: Future + Unpin, B: Future + Unpin, C: Future + Unpin, D: Future + Unpin, - E: Future + Unpin, + E: Future + Unpin, > Future for Selector { type Output = SelectorOutput; fn poll( mut self: Pin<&mut Self>, ctx: &mut core::task::Context<'_>, ) -> Poll { + // Bias the selector so it first polls the sleeper future, allowing to exit immediately + // if the flag is set. match Pin::new(&mut self.a).poll(ctx) { - Poll::Ready(()) => { - return Poll::Ready(SelectorOutput::A); + Poll::Ready(res) => { + return Poll::Ready(SelectorOutput::A(res)); }, Poll::Pending => {}, } @@ -534,8 +536,8 @@ pub(crate) mod futures_util { Poll::Pending => {}, } match Pin::new(&mut self.e).poll(ctx) { - Poll::Ready(res) => { - return Poll::Ready(SelectorOutput::E(res)); + Poll::Ready(()) => { + return Poll::Ready(SelectorOutput::E); }, Poll::Pending => {}, } @@ -1037,15 +1039,15 @@ where (false, false) => FASTEST_TIMER, }; let fut = Selector { - a: channel_manager.get_cm().get_event_or_persistence_needed_future(), - b: chain_monitor.get_update_future(), - c: om_fut, - d: lm_fut, - e: sleeper(sleep_delay), + a: sleeper(sleep_delay), + b: channel_manager.get_cm().get_event_or_persistence_needed_future(), + c: chain_monitor.get_update_future(), + d: om_fut, + e: lm_fut, }; match fut.await { - SelectorOutput::A | SelectorOutput::B | SelectorOutput::C | SelectorOutput::D => {}, - SelectorOutput::E(exit) => { + SelectorOutput::B | SelectorOutput::C | SelectorOutput::D | SelectorOutput::E => {}, + SelectorOutput::A(exit) => { if exit { break; } From 6dac504a2ce390441b8cefd62f66927f383713ba Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Fri, 23 Jan 2026 14:44:04 +0100 Subject: [PATCH 009/135] `ElectrumSyncClient`: Skip unconfirmed `get_history` entries Electrum's `blockchain.scripthash.get_history` will return the *confirmed* history for any scripthash, but will then also append any matching entries from the mempool, with respective `height` fields set to 0 or -1 (depending on whether all inputs are confirmed or not). Unfortunately we previously only included a filter for confirmed `get_history` entries in the watched output case, and forgot to add such a check also when checking for watched transactions. This would have us treat the entry as confirmed, then failing on the `get_merkle` step which of course couldn't prove block inclusion. Here we simply fix this omission and skip entries that are still unconfirmed (e.g., unconfirmed funding transactions from 0conf channels). Signed-off-by: Elias Rohrer Backport of cc1eb1686ce9ec6dd3d2043a286010bb1a759e63 --- lightning-transaction-sync/src/electrum.rs | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/lightning-transaction-sync/src/electrum.rs b/lightning-transaction-sync/src/electrum.rs index 47489df69bb..1162b9c00c9 100644 --- a/lightning-transaction-sync/src/electrum.rs +++ b/lightning-transaction-sync/src/electrum.rs @@ -336,10 +336,21 @@ where 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 { + // Skip if it's a an unconfirmed entry. + continue; + } let confirmed_tx = self.get_confirmed_tx(tx, prob_conf_height)?; confirmed_txs.push(confirmed_tx); } - debug_assert!(filtered_history.next().is_none()); + if filtered_history.next().is_some() { + log_error!( + self.logger, + "Failed due to server returning multiple history entries for Tx {}.", + txid + ); + return Err(InternalError::Failed); + } } for (watched_output, script_history) in @@ -347,6 +358,7 @@ where { for possible_output_spend in script_history { if possible_output_spend.height <= 0 { + // Skip if it's a an unconfirmed entry. continue; } From 50dcc1d4ef17985f455d8a434c0534a22d001e6d Mon Sep 17 00:00:00 2001 From: Matt Corallo Date: Sun, 25 Jan 2026 14:30:10 +0000 Subject: [PATCH 010/135] Use a single `WithContext` wrapper rather than several log-wrappers In `ChannelMonitor` logging, we often wrap a logger with `WithChannelMonitor` to automatically include metadata in our structured logging. That's great, except having too many logger wrapping types flying around makes for less compatibility if we have methods that want to require a wrapped-logger. Here we change the `WithChannelMonitor` "constructors" to actually return a `WithContext` instead, making things more consistent. Backport of 0f253c0b9d310e074668bb00a724a2c3b0ba6620 --- lightning/src/chain/channelmonitor.rs | 77 ++++++++++----------------- 1 file changed, 29 insertions(+), 48 deletions(-) diff --git a/lightning/src/chain/channelmonitor.rs b/lightning/src/chain/channelmonitor.rs index ab695ee3530..e52b05036ec 100644 --- a/lightning/src/chain/channelmonitor.rs +++ b/lightning/src/chain/channelmonitor.rs @@ -66,7 +66,7 @@ use crate::sign::{ use crate::types::features::ChannelTypeFeatures; use crate::types::payment::{PaymentHash, PaymentPreimage}; use crate::util::byte_utils; -use crate::util::logger::{Logger, Record}; +use crate::util::logger::{Logger, WithContext}; use crate::util::persist::MonitorName; use crate::util::ser::{ MaybeReadable, Readable, ReadableArgs, RequiredWrapper, UpgradableRequired, Writeable, Writer, @@ -1825,45 +1825,27 @@ macro_rules! _process_events_body { } pub(super) use _process_events_body as process_events_body; -pub(crate) struct WithChannelMonitor<'a, L: Deref> -where - L::Target: Logger, -{ - logger: &'a L, - peer_id: Option, - channel_id: Option, - payment_hash: Option, -} +pub(crate) struct WithChannelMonitor; -impl<'a, L: Deref> Logger for WithChannelMonitor<'a, L> -where - L::Target: Logger, -{ - fn log(&self, mut record: Record) { - record.peer_id = self.peer_id; - record.channel_id = self.channel_id; - record.payment_hash = self.payment_hash; - self.logger.log(record) - } -} - -impl<'a, L: Deref> WithChannelMonitor<'a, L> -where - L::Target: Logger, -{ - pub(crate) fn from( +impl WithChannelMonitor { + pub(crate) fn from<'a, L: Deref, S: EcdsaChannelSigner>( logger: &'a L, monitor: &ChannelMonitor, payment_hash: Option, - ) -> Self { + ) -> WithContext<'a, L> + where + L::Target: Logger, + { Self::from_impl(logger, &*monitor.inner.lock().unwrap(), payment_hash) } - #[rustfmt::skip] - pub(crate) fn from_impl(logger: &'a L, monitor_impl: &ChannelMonitorImpl, payment_hash: Option) -> Self { + pub(crate) fn from_impl<'a, L: Deref, S: EcdsaChannelSigner>( + logger: &'a L, monitor_impl: &ChannelMonitorImpl, payment_hash: Option, + ) -> WithContext<'a, L> + where + L::Target: Logger, + { let peer_id = Some(monitor_impl.counterparty_node_id); let channel_id = Some(monitor_impl.channel_id()); - WithChannelMonitor { - logger, peer_id, channel_id, payment_hash, - } + WithContext::from(logger, peer_id, channel_id, payment_hash) } } @@ -3829,7 +3811,7 @@ impl ChannelMonitorImpl { fn provide_payment_preimage( &mut self, payment_hash: &PaymentHash, payment_preimage: &PaymentPreimage, payment_info: &Option, broadcaster: &B, - fee_estimator: &LowerBoundedFeeEstimator, logger: &WithChannelMonitor) + fee_estimator: &LowerBoundedFeeEstimator, logger: &WithContext) where B::Target: BroadcasterInterface, F::Target: FeeEstimator, L::Target: Logger, @@ -4006,7 +3988,7 @@ impl ChannelMonitorImpl { /// /// [`ChannelMonitor::broadcast_latest_holder_commitment_txn`]: crate::chain::channelmonitor::ChannelMonitor::broadcast_latest_holder_commitment_txn pub(crate) fn queue_latest_holder_commitment_txn_for_broadcast( - &mut self, broadcaster: &B, fee_estimator: &LowerBoundedFeeEstimator, logger: &WithChannelMonitor, + &mut self, broadcaster: &B, fee_estimator: &LowerBoundedFeeEstimator, logger: &WithContext, require_funding_seen: bool, ) where @@ -4034,8 +4016,7 @@ impl ChannelMonitorImpl { } fn renegotiated_funding( - &mut self, logger: &WithChannelMonitor, - channel_parameters: &ChannelTransactionParameters, + &mut self, logger: &WithContext, channel_parameters: &ChannelTransactionParameters, alternative_holder_commitment_tx: &HolderCommitmentTransaction, alternative_counterparty_commitment_tx: &CommitmentTransaction, ) -> Result<(), ()> @@ -4210,7 +4191,7 @@ impl ChannelMonitorImpl { #[rustfmt::skip] fn update_monitor( - &mut self, updates: &ChannelMonitorUpdate, broadcaster: &B, fee_estimator: &F, logger: &WithChannelMonitor + &mut self, updates: &ChannelMonitorUpdate, broadcaster: &B, fee_estimator: &F, logger: &WithContext ) -> Result<(), ()> where B::Target: BroadcasterInterface, F::Target: FeeEstimator, @@ -5254,7 +5235,7 @@ impl ChannelMonitorImpl { /// Note that this includes possibly-locktimed-in-the-future transactions! #[rustfmt::skip] fn unsafe_get_latest_holder_commitment_txn( - &mut self, logger: &WithChannelMonitor + &mut self, logger: &WithContext ) -> Vec where L::Target: Logger { log_debug!(logger, "Getting signed copy of latest holder commitment transaction!"); let commitment_tx = { @@ -5307,7 +5288,7 @@ impl ChannelMonitorImpl { #[rustfmt::skip] fn block_connected( &mut self, header: &Header, txdata: &TransactionData, height: u32, broadcaster: B, - fee_estimator: F, logger: &WithChannelMonitor, + fee_estimator: F, logger: &WithContext, ) -> Vec where B::Target: BroadcasterInterface, F::Target: FeeEstimator, @@ -5327,7 +5308,7 @@ impl ChannelMonitorImpl { height: u32, broadcaster: B, fee_estimator: &LowerBoundedFeeEstimator, - logger: &WithChannelMonitor, + logger: &WithContext, ) -> Vec where B::Target: BroadcasterInterface, @@ -5360,7 +5341,7 @@ impl ChannelMonitorImpl { height: u32, broadcaster: B, fee_estimator: &LowerBoundedFeeEstimator, - logger: &WithChannelMonitor, + logger: &WithContext, ) -> Vec where B::Target: BroadcasterInterface, @@ -5648,7 +5629,7 @@ impl ChannelMonitorImpl { mut claimable_outpoints: Vec, broadcaster: &B, fee_estimator: &LowerBoundedFeeEstimator, - logger: &WithChannelMonitor, + logger: &WithContext, ) -> Vec where B::Target: BroadcasterInterface, @@ -5868,7 +5849,7 @@ impl ChannelMonitorImpl { #[rustfmt::skip] fn blocks_disconnected( - &mut self, fork_point: BestBlock, broadcaster: B, fee_estimator: F, logger: &WithChannelMonitor + &mut self, fork_point: BestBlock, broadcaster: B, fee_estimator: F, logger: &WithContext ) where B::Target: BroadcasterInterface, F::Target: FeeEstimator, L::Target: Logger, @@ -5921,7 +5902,7 @@ impl ChannelMonitorImpl { txid: &Txid, broadcaster: B, fee_estimator: &LowerBoundedFeeEstimator, - logger: &WithChannelMonitor, + logger: &WithContext, ) where B::Target: BroadcasterInterface, F::Target: FeeEstimator, @@ -6032,7 +6013,7 @@ impl ChannelMonitorImpl { #[rustfmt::skip] fn should_broadcast_holder_commitment_txn( - &self, logger: &WithChannelMonitor + &self, logger: &WithContext ) -> Option where L::Target: Logger { // There's no need to broadcast our commitment transaction if we've seen one confirmed (even // with 1 confirmation) as it'll be rejected as duplicate/conflicting. @@ -6114,7 +6095,7 @@ impl ChannelMonitorImpl { /// or counterparty commitment tx, if so send back the source, preimage if found and payment_hash of resolved HTLC #[rustfmt::skip] fn is_resolving_htlc_output( - &mut self, tx: &Transaction, height: u32, block_hash: &BlockHash, logger: &WithChannelMonitor, + &mut self, tx: &Transaction, height: u32, block_hash: &BlockHash, logger: &WithContext, ) where L::Target: Logger { let funding_spent = get_confirmed_funding_scope!(self); @@ -6371,7 +6352,7 @@ impl ChannelMonitorImpl { /// own. #[rustfmt::skip] fn check_tx_and_push_spendable_outputs( - &mut self, tx: &Transaction, height: u32, block_hash: &BlockHash, logger: &WithChannelMonitor, + &mut self, tx: &Transaction, height: u32, block_hash: &BlockHash, logger: &WithContext, ) where L::Target: Logger { let funding_spent = get_confirmed_funding_scope!(self); for spendable_output in self.get_spendable_outputs(funding_spent, tx) { From 19259ea78fd32ac86ec0d41761d3759f51719668 Mon Sep 17 00:00:00 2001 From: Matt Corallo Date: Sun, 25 Jan 2026 14:39:19 +0000 Subject: [PATCH 011/135] Require `WithContext` log wrappers on `OutboundPayments` calls In much of LDK we pass around `Logger` objects both to avoid having to `Clone` `Logger` `Deref`s (soon to only be `Logger`s) and to allow us to set context with a wrapper such that any log calls on that wrapper get additional useful metadata in them. Sadly, when we added a `Logger` type to `OutboundPayments` we broke the ability to do the second thing - payment information logged directly or indirectly via logic in the `OutboundPayments` has no context making log-searching rather challenging. Here we fix this by retunring to passing loggers explicitly to `OutboundPayments` methods that need them, specifically requiring `WithContext` wrappers to ensure the callsite sets appropriate context on the logger. Fixes #4307 Backport of 5e64c4018492cba44761fbd9427ac0df0cc7cdd2 Conflicts resolved in: * lightning/src/ln/channelmanager.rs --- lightning/src/ln/channelmanager.rs | 36 +++- lightning/src/ln/outbound_payment.rs | 265 +++++++++++++++------------ 2 files changed, 174 insertions(+), 127 deletions(-) diff --git a/lightning/src/ln/channelmanager.rs b/lightning/src/ln/channelmanager.rs index 9cf17663185..02bb67e15c4 100644 --- a/lightning/src/ln/channelmanager.rs +++ b/lightning/src/ln/channelmanager.rs @@ -2719,7 +2719,7 @@ pub struct ChannelManager< /// See `PendingOutboundPayment` documentation for more info. /// /// See `ChannelManager` struct-level documentation for lock order requirements. - pending_outbound_payments: OutboundPayments, + pending_outbound_payments: OutboundPayments, /// SCID/SCID Alias -> forward infos. Key of 0 means payments received. /// @@ -3977,7 +3977,7 @@ where best_block: RwLock::new(params.best_block), outbound_scid_aliases: Mutex::new(new_hash_set()), - pending_outbound_payments: OutboundPayments::new(new_hash_map(), logger.clone()), + pending_outbound_payments: OutboundPayments::new(new_hash_map()), 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() }), @@ -5399,11 +5399,12 @@ where }); if route.route_params.is_none() { route.route_params = Some(route_params.clone()); } let router = FixedRouter::new(route); + let logger = WithContext::from(&self.logger, None, None, Some(payment_hash)); 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)) + &self.pending_events, |args| self.send_payment_along_path(args), &logger) } /// Sends a payment to the route found using the provided [`RouteParameters`], retrying failed @@ -5463,6 +5464,7 @@ where best_block_height, &self.pending_events, |args| self.send_payment_along_path(args), + &WithContext::from(&self.logger, None, None, Some(payment_hash)), ) } @@ -5547,6 +5549,7 @@ where ) -> Result<(), Bolt11PaymentError> { let best_block_height = self.best_block.read().unwrap().height; let _persistence_guard = PersistenceNotifierGuard::notify_on_drop(self); + let payment_hash = PaymentHash(invoice.payment_hash().to_byte_array()); self.pending_outbound_payments.pay_for_bolt11_invoice( invoice, payment_id, @@ -5561,6 +5564,7 @@ where best_block_height, &self.pending_events, |args| self.send_payment_along_path(args), + &WithContext::from(&self.logger, None, None, Some(payment_hash)), ) } @@ -5630,6 +5634,7 @@ where best_block_height, &self.pending_events, |args| self.send_payment_along_path(args), + &WithContext::from(&self.logger, None, None, None), ) } @@ -5812,6 +5817,7 @@ where best_block_height, &self.pending_events, |args| self.send_payment_along_path(args), + &WithContext::from(&self.logger, None, None, None), ) } @@ -5890,6 +5896,7 @@ where ) -> Result { let best_block_height = self.best_block.read().unwrap().height; let _persistence_guard = PersistenceNotifierGuard::notify_on_drop(self); + let payment_hash = payment_preimage.map(|preimage| preimage.into()); self.pending_outbound_payments.send_spontaneous_payment( payment_preimage, recipient_onion, @@ -5904,6 +5911,7 @@ where best_block_height, &self.pending_events, |args| self.send_payment_along_path(args), + &WithContext::from(&self.logger, None, None, payment_hash), ) } @@ -7163,6 +7171,7 @@ where best_block_height, &self.pending_events, |args| self.send_payment_along_path(args), + &WithContext::from(&self.logger, None, None, None), ); if needs_persist { should_persist = NotifyOption::DoPersist; @@ -8538,6 +8547,7 @@ where // being fully configured. See the docs for `ChannelManagerReadArgs` for more. match source { HTLCSource::OutboundRoute { ref path, ref session_priv, ref payment_id, .. } => { + let logger = WithContext::from(&self.logger, None, None, Some(*payment_hash)); self.pending_outbound_payments.fail_htlc( source, payment_hash, @@ -8549,6 +8559,7 @@ where &self.secp_ctx, &self.pending_events, &mut from_monitor_update_completion, + &logger, ); if let Some(update) = from_monitor_update_completion { // If `fail_htlc` didn't `take` the post-event action, we should go ahead and @@ -9229,6 +9240,7 @@ This indicates a bug inside LDK. Please report this error at https://github.com/ from_onchain, &mut ev_completion_action, &self.pending_events, + &WithContext::from(&self.logger, None, None, Some(payment_preimage.into())), ); // If an event was generated, `claim_htlc` set `ev_completion_action` to None, if // not, we should go ahead and run it now (as the claim was duplicative), at least @@ -17166,8 +17178,7 @@ where } pending_outbound_payments = Some(outbounds); } - let pending_outbounds = - OutboundPayments::new(pending_outbound_payments.unwrap(), args.logger.clone()); + let pending_outbounds = OutboundPayments::new(pending_outbound_payments.unwrap()); for (peer_pubkey, peer_storage) in peer_storage_dir { if let Some(peer_state) = per_peer_state.get_mut(&peer_pubkey) { @@ -17473,6 +17484,7 @@ where session_priv_bytes, &path, best_block_height, + &logger, ); } } @@ -17572,6 +17584,7 @@ where true, &mut compl_action, &pending_events, + &logger, ); // If the completion action was not consumed, then there was no // payment to claim, and we need to tell the `ChannelMonitor` @@ -17616,8 +17629,10 @@ where } } for (htlc_source, payment_hash) in monitor.get_onchain_failed_outbound_htlcs() { + let logger = + WithChannelMonitor::from(&args.logger, monitor, Some(payment_hash)); log_info!( - args.logger, + logger, "Failing HTLC with payment hash {} as it was resolved on-chain.", payment_hash ); @@ -17686,6 +17701,11 @@ where // 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 @@ -17708,7 +17728,7 @@ where // 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!(args.logger, + 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, @@ -17723,7 +17743,7 @@ where // 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!(args.logger, + 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\ diff --git a/lightning/src/ln/outbound_payment.rs b/lightning/src/ln/outbound_payment.rs index 75fe55bfeac..60c33b09bea 100644 --- a/lightning/src/ln/outbound_payment.rs +++ b/lightning/src/ln/outbound_payment.rs @@ -34,7 +34,7 @@ use crate::sign::{EntropySource, NodeSigner, Recipient}; use crate::types::features::Bolt12InvoiceFeatures; use crate::types::payment::{PaymentHash, PaymentPreimage, PaymentSecret}; use crate::util::errors::APIError; -use crate::util::logger::Logger; +use crate::util::logger::{Logger, WithContext}; use crate::util::ser::ReadableArgs; #[cfg(feature = "std")] use crate::util::time::Instant; @@ -837,22 +837,15 @@ pub(super) struct SendAlongPathArgs<'a> { pub hold_htlc_at_next_hop: bool, } -pub(super) struct OutboundPayments -where - L::Target: Logger, -{ +pub(super) struct OutboundPayments { pub(super) pending_outbound_payments: Mutex>, awaiting_invoice: AtomicBool, retry_lock: Mutex<()>, - logger: L, } -impl OutboundPayments -where - L::Target: Logger, -{ +impl OutboundPayments { pub(super) fn new( - pending_outbound_payments: HashMap, logger: L, + pending_outbound_payments: HashMap, ) -> Self { let has_invoice_requests = pending_outbound_payments.values().any(|payment| { matches!( @@ -867,17 +860,19 @@ where pending_outbound_payments: Mutex::new(pending_outbound_payments), awaiting_invoice: AtomicBool::new(has_invoice_requests), retry_lock: Mutex::new(()), - logger, } } +} +impl OutboundPayments { #[rustfmt::skip] - pub(super) fn send_payment( + pub(super) fn send_payment( &self, payment_hash: PaymentHash, recipient_onion: RecipientOnionFields, payment_id: PaymentId, retry_strategy: Retry, route_params: RouteParameters, router: &R, first_hops: Vec, compute_inflight_htlcs: IH, entropy_source: &ES, node_signer: &NS, best_block_height: u32, pending_events: &Mutex)>>, send_payment_along_path: SP, + logger: &WithContext, ) -> Result<(), RetryableSendFailure> where R::Target: Router, @@ -885,19 +880,21 @@ where NS::Target: NodeSigner, IH: Fn() -> InFlightHtlcs, SP: Fn(SendAlongPathArgs) -> Result<(), APIError>, + L::Target: Logger, { self.send_payment_for_non_bolt12_invoice(payment_id, payment_hash, recipient_onion, None, retry_strategy, route_params, router, first_hops, &compute_inflight_htlcs, entropy_source, node_signer, - best_block_height, pending_events, &send_payment_along_path) + best_block_height, pending_events, &send_payment_along_path, logger) } #[rustfmt::skip] - pub(super) fn send_spontaneous_payment( + pub(super) fn send_spontaneous_payment( &self, payment_preimage: Option, recipient_onion: RecipientOnionFields, payment_id: PaymentId, retry_strategy: Retry, route_params: RouteParameters, router: &R, first_hops: Vec, inflight_htlcs: IH, entropy_source: &ES, node_signer: &NS, best_block_height: u32, - pending_events: &Mutex)>>, send_payment_along_path: SP + pending_events: &Mutex)>>, send_payment_along_path: SP, + logger: &WithContext, ) -> Result where R::Target: Router, @@ -905,18 +902,20 @@ where NS::Target: NodeSigner, IH: Fn() -> InFlightHtlcs, SP: Fn(SendAlongPathArgs) -> Result<(), APIError>, + L::Target: Logger, { let preimage = payment_preimage .unwrap_or_else(|| PaymentPreimage(entropy_source.get_secure_random_bytes())); let payment_hash = PaymentHash(Sha256::hash(&preimage.0).to_byte_array()); self.send_payment_for_non_bolt12_invoice(payment_id, payment_hash, recipient_onion, Some(preimage), retry_strategy, route_params, router, first_hops, inflight_htlcs, entropy_source, - node_signer, best_block_height, pending_events, send_payment_along_path) - .map(|()| payment_hash) + node_signer, best_block_height, pending_events, send_payment_along_path, logger, + ) + .map(|()| payment_hash) } #[rustfmt::skip] - pub(super) fn pay_for_bolt11_invoice( + pub(super) fn pay_for_bolt11_invoice( &self, invoice: &Bolt11Invoice, payment_id: PaymentId, amount_msats: Option, route_params_config: RouteParametersConfig, @@ -925,6 +924,7 @@ where first_hops: Vec, compute_inflight_htlcs: IH, entropy_source: &ES, node_signer: &NS, best_block_height: u32, pending_events: &Mutex)>>, send_payment_along_path: SP, + logger: &WithContext, ) -> Result<(), Bolt11PaymentError> where R::Target: Router, @@ -932,6 +932,7 @@ where NS::Target: NodeSigner, IH: Fn() -> InFlightHtlcs, SP: Fn(SendAlongPathArgs) -> Result<(), APIError>, + L::Target: Logger, { let payment_hash = PaymentHash((*invoice.payment_hash()).to_byte_array()); @@ -957,20 +958,20 @@ where self.send_payment_for_non_bolt12_invoice(payment_id, payment_hash, recipient_onion, None, retry_strategy, route_params, router, first_hops, compute_inflight_htlcs, entropy_source, node_signer, best_block_height, - pending_events, send_payment_along_path + pending_events, send_payment_along_path, logger, ).map_err(|err| Bolt11PaymentError::SendingFailed(err)) } #[rustfmt::skip] pub(super) fn send_payment_for_bolt12_invoice< - R: Deref, ES: Deref, NS: Deref, NL: Deref, IH, SP + R: Deref, ES: Deref, NS: Deref, NL: Deref, IH, SP, L: Deref, >( &self, invoice: &Bolt12Invoice, payment_id: PaymentId, router: &R, first_hops: Vec, features: Bolt12InvoiceFeatures, inflight_htlcs: IH, entropy_source: &ES, node_signer: &NS, node_id_lookup: &NL, secp_ctx: &Secp256k1, best_block_height: u32, pending_events: &Mutex)>>, - send_payment_along_path: SP, + send_payment_along_path: SP, logger: &WithContext, ) -> Result<(), Bolt12PaymentError> where R::Target: Router, @@ -979,6 +980,7 @@ where NL::Target: NodeIdLookUp, IH: Fn() -> InFlightHtlcs, SP: Fn(SendAlongPathArgs) -> Result<(), APIError>, + L::Target: Logger, { let (payment_hash, retry_strategy, params_config, _) = self @@ -1002,13 +1004,13 @@ where self.send_payment_for_bolt12_invoice_internal( payment_id, payment_hash, None, None, invoice, route_params, retry_strategy, false, router, first_hops, inflight_htlcs, entropy_source, node_signer, node_id_lookup, secp_ctx, - best_block_height, pending_events, send_payment_along_path + best_block_height, pending_events, send_payment_along_path, logger, ) } #[rustfmt::skip] fn send_payment_for_bolt12_invoice_internal< - R: Deref, ES: Deref, NS: Deref, NL: Deref, IH, SP + R: Deref, ES: Deref, NS: Deref, NL: Deref, IH, SP, L: Deref, >( &self, payment_id: PaymentId, payment_hash: PaymentHash, keysend_preimage: Option, invoice_request: Option<&InvoiceRequest>, @@ -1017,7 +1019,7 @@ where first_hops: Vec, inflight_htlcs: IH, entropy_source: &ES, node_signer: &NS, node_id_lookup: &NL, secp_ctx: &Secp256k1, best_block_height: u32, pending_events: &Mutex)>>, - send_payment_along_path: SP, + send_payment_along_path: SP, logger: &WithContext, ) -> Result<(), Bolt12PaymentError> where R::Target: Router, @@ -1026,6 +1028,7 @@ where NL::Target: NodeIdLookUp, IH: Fn() -> InFlightHtlcs, SP: Fn(SendAlongPathArgs) -> Result<(), APIError>, + L::Target: Logger, { // Advance any blinded path where the introduction node is our node. if let Ok(our_node_id) = node_signer.get_node_id(Recipient::Node) { @@ -1053,6 +1056,7 @@ where let route = match self.find_initial_route( payment_id, payment_hash, &recipient_onion, keysend_preimage, invoice_request, &mut route_params, router, &first_hops, &inflight_htlcs, node_signer, best_block_height, + logger, ) { Ok(route) => route, Err(e) => { @@ -1102,14 +1106,14 @@ where best_block_height, &send_payment_along_path ); log_info!( - self.logger, "Sending payment with id {} and hash {} returned {:?}", payment_id, + logger, "Sending payment with id {} and hash {} returned {:?}", payment_id, payment_hash, result ); if let Err(e) = result { self.handle_pay_route_err( e, payment_id, payment_hash, route, route_params, onion_session_privs, router, first_hops, &inflight_htlcs, entropy_source, node_signer, best_block_height, pending_events, - &send_payment_along_path + &send_payment_along_path, logger, ); } Ok(()) @@ -1231,12 +1235,13 @@ where NL: Deref, IH, SP, + L: Deref, >( &self, payment_id: PaymentId, hold_htlcs_at_next_hop: bool, router: &R, first_hops: Vec, inflight_htlcs: IH, entropy_source: &ES, node_signer: &NS, node_id_lookup: &NL, secp_ctx: &Secp256k1, best_block_height: u32, pending_events: &Mutex)>>, - send_payment_along_path: SP, + send_payment_along_path: SP, logger: &WithContext, ) -> Result<(), Bolt12PaymentError> where R::Target: Router, @@ -1245,6 +1250,7 @@ where NL::Target: NodeIdLookUp, IH: Fn() -> InFlightHtlcs, SP: Fn(SendAlongPathArgs) -> Result<(), APIError>, + L::Target: Logger, { let ( payment_hash, @@ -1303,15 +1309,16 @@ where best_block_height, pending_events, send_payment_along_path, + logger, ) } // Returns whether the data changed and needs to be repersisted. - pub(super) fn check_retry_payments( + pub(super) fn check_retry_payments( &self, router: &R, first_hops: FH, inflight_htlcs: IH, entropy_source: &ES, node_signer: &NS, best_block_height: u32, pending_events: &Mutex)>>, - send_payment_along_path: SP, + send_payment_along_path: SP, logger: &WithContext, ) -> bool where R::Target: Router, @@ -1320,6 +1327,7 @@ where SP: Fn(SendAlongPathArgs) -> Result<(), APIError>, IH: Fn() -> InFlightHtlcs, FH: Fn() -> Vec, + L::Target: Logger, { let _single_thread = self.retry_lock.lock().unwrap(); let mut should_persist = false; @@ -1369,6 +1377,7 @@ where best_block_height, pending_events, &send_payment_along_path, + logger, ); should_persist = true; } else { @@ -1414,11 +1423,11 @@ where } #[rustfmt::skip] - fn find_initial_route( + fn find_initial_route( &self, payment_id: PaymentId, payment_hash: PaymentHash, recipient_onion: &RecipientOnionFields, keysend_preimage: Option, invoice_request: Option<&InvoiceRequest>, route_params: &mut RouteParameters, router: &R, first_hops: &Vec, - inflight_htlcs: &IH, node_signer: &NS, best_block_height: u32, + inflight_htlcs: &IH, node_signer: &NS, best_block_height: u32, logger: &WithContext, ) -> Result where R::Target: Router, @@ -1428,7 +1437,7 @@ where { #[cfg(feature = "std")] { if has_expired(&route_params) { - log_error!(self.logger, "Payment with id {} and hash {} had expired before we started paying", + log_error!(logger, "Payment with id {} and hash {} had expired before we started paying", payment_id, payment_hash); return Err(RetryableSendFailure::PaymentExpired) } @@ -1438,7 +1447,7 @@ where route_params, recipient_onion, keysend_preimage, invoice_request, best_block_height ) .map_err(|()| { - log_error!(self.logger, "Can't construct an onion packet without exceeding 1300-byte onion \ + log_error!(logger, "Can't construct an onion packet without exceeding 1300-byte onion \ hop_data length for payment with id {} and hash {}", payment_id, payment_hash); RetryableSendFailure::OnionPacketSizeExceeded })?; @@ -1448,7 +1457,7 @@ where Some(&first_hops.iter().collect::>()), inflight_htlcs(), payment_hash, payment_id, ).map_err(|_| { - log_error!(self.logger, "Failed to find route for payment with id {} and hash {}", + log_error!(logger, "Failed to find route for payment with id {} and hash {}", payment_id, payment_hash); RetryableSendFailure::RouteNotFound })?; @@ -1469,12 +1478,13 @@ where /// [`Event::PaymentPathFailed`]: crate::events::Event::PaymentPathFailed /// [`Event::PaymentFailed`]: crate::events::Event::PaymentFailed #[rustfmt::skip] - fn send_payment_for_non_bolt12_invoice( + fn send_payment_for_non_bolt12_invoice( &self, payment_id: PaymentId, payment_hash: PaymentHash, recipient_onion: RecipientOnionFields, keysend_preimage: Option, retry_strategy: Retry, mut route_params: RouteParameters, router: &R, first_hops: Vec, inflight_htlcs: IH, entropy_source: &ES, node_signer: &NS, best_block_height: u32, pending_events: &Mutex)>>, send_payment_along_path: SP, + logger: &WithContext, ) -> Result<(), RetryableSendFailure> where R::Target: Router, @@ -1486,14 +1496,14 @@ where { let route = self.find_initial_route( payment_id, payment_hash, &recipient_onion, keysend_preimage, None, &mut route_params, router, - &first_hops, &inflight_htlcs, node_signer, best_block_height, + &first_hops, &inflight_htlcs, node_signer, best_block_height, logger, )?; 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) .map_err(|_| { - log_error!(self.logger, "Payment with id {} is already pending. New payment had payment hash {}", + log_error!(logger, "Payment with id {} is already pending. New payment had payment hash {}", payment_id, payment_hash); RetryableSendFailure::DuplicatePayment })?; @@ -1501,24 +1511,25 @@ where let res = self.pay_route_internal(&route, payment_hash, &recipient_onion, keysend_preimage, None, None, payment_id, None, &onion_session_privs, false, node_signer, best_block_height, &send_payment_along_path); - log_info!(self.logger, "Sending payment with id {} and hash {} returned {:?}", + log_info!(logger, "Sending payment with id {} and hash {} returned {:?}", payment_id, payment_hash, res); if let Err(e) = res { self.handle_pay_route_err( e, payment_id, payment_hash, route, route_params, onion_session_privs, router, first_hops, &inflight_htlcs, entropy_source, node_signer, best_block_height, pending_events, - &send_payment_along_path + &send_payment_along_path, logger, ); } Ok(()) } #[rustfmt::skip] - fn find_route_and_send_payment( + fn find_route_and_send_payment( &self, payment_hash: PaymentHash, payment_id: PaymentId, route_params: RouteParameters, router: &R, first_hops: Vec, inflight_htlcs: &IH, entropy_source: &ES, node_signer: &NS, best_block_height: u32, - pending_events: &Mutex)>>, send_payment_along_path: &SP, + pending_events: &Mutex)>>, + send_payment_along_path: &SP, logger: &WithContext, ) where R::Target: Router, @@ -1530,7 +1541,7 @@ where { #[cfg(feature = "std")] { if has_expired(&route_params) { - log_error!(self.logger, "Payment params expired on retry, abandoning payment {}", &payment_id); + log_error!(logger, "Payment params expired on retry, abandoning payment {}", &payment_id); self.abandon_payment(payment_id, PaymentFailureReason::PaymentExpired, pending_events); return } @@ -1543,7 +1554,7 @@ where ) { Ok(route) => route, Err(e) => { - log_error!(self.logger, "Failed to find a route on retry, abandoning payment {}: {:#?}", &payment_id, e); + log_error!(logger, "Failed to find a route on retry, abandoning payment {}: {:#?}", &payment_id, e); self.abandon_payment(payment_id, PaymentFailureReason::RouteNotFound, pending_events); return } @@ -1557,7 +1568,7 @@ where for path in route.paths.iter() { if path.hops.len() == 0 { - log_error!(self.logger, "Unusable path in route (path.hops.len() must be at least 1"); + 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 } @@ -1590,13 +1601,13 @@ where const RETRY_OVERFLOW_PERCENTAGE: u64 = 10; let retry_amt_msat = route.get_total_amount(); if retry_amt_msat + *pending_amt_msat > *total_msat * (100 + RETRY_OVERFLOW_PERCENTAGE) / 100 { - log_error!(self.logger, "retry_amt_msat of {} will put pending_amt_msat (currently: {}) more than 10% over total_payment_amt_msat of {}", retry_amt_msat, pending_amt_msat, total_msat); + log_error!(logger, "retry_amt_msat of {} will put pending_amt_msat (currently: {}) more than 10% over total_payment_amt_msat of {}", retry_amt_msat, pending_amt_msat, total_msat); abandon_with_entry!(payment, PaymentFailureReason::UnexpectedError); return } if !payment.get().is_retryable_now() { - log_error!(self.logger, "Retries exhausted for payment id {}", &payment_id); + log_error!(logger, "Retries exhausted for payment id {}", &payment_id); abandon_with_entry!(payment, PaymentFailureReason::RetriesExhausted); return } @@ -1625,38 +1636,38 @@ where (total_msat, recipient_onion, keysend_preimage, onion_session_privs, invoice_request, bolt12_invoice.cloned()) }, PendingOutboundPayment::Legacy { .. } => { - log_error!(self.logger, "Unable to retry payments that were initially sent on LDK versions prior to 0.0.102"); + log_error!(logger, "Unable to retry payments that were initially sent on LDK versions prior to 0.0.102"); return }, PendingOutboundPayment::AwaitingInvoice { .. } | PendingOutboundPayment::AwaitingOffer { .. } => { - log_error!(self.logger, "Payment not yet sent"); + log_error!(logger, "Payment not yet sent"); debug_assert!(false); return }, PendingOutboundPayment::InvoiceReceived { .. } => { - log_error!(self.logger, "Payment already initiating"); + log_error!(logger, "Payment already initiating"); debug_assert!(false); return }, PendingOutboundPayment::StaticInvoiceReceived { .. } => { - log_error!(self.logger, "Payment already initiating"); + log_error!(logger, "Payment already initiating"); debug_assert!(false); return }, PendingOutboundPayment::Fulfilled { .. } => { - log_error!(self.logger, "Payment already completed"); + log_error!(logger, "Payment already completed"); return }, PendingOutboundPayment::Abandoned { .. } => { - log_error!(self.logger, "Payment already abandoned (with some HTLCs still pending)"); + log_error!(logger, "Payment already abandoned (with some HTLCs still pending)"); return }, } }, hash_map::Entry::Vacant(_) => { - log_error!(self.logger, "Payment with ID {} not found", &payment_id); + log_error!(logger, "Payment with ID {} not found", &payment_id); return } } @@ -1664,24 +1675,24 @@ where 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), &onion_session_privs, false, node_signer, best_block_height, &send_payment_along_path); - log_info!(self.logger, "Result retrying payment id {}: {:?}", &payment_id, res); + log_info!(logger, "Result retrying payment id {}: {:?}", &payment_id, res); if let Err(e) = res { self.handle_pay_route_err( e, payment_id, payment_hash, route, route_params, onion_session_privs, router, first_hops, inflight_htlcs, entropy_source, node_signer, best_block_height, pending_events, - send_payment_along_path + send_payment_along_path, logger ); } } #[rustfmt::skip] - fn handle_pay_route_err( + fn handle_pay_route_err( &self, err: PaymentSendFailure, payment_id: PaymentId, payment_hash: PaymentHash, route: Route, mut route_params: RouteParameters, onion_session_privs: Vec<[u8; 32]>, router: &R, first_hops: Vec, inflight_htlcs: &IH, entropy_source: &ES, node_signer: &NS, best_block_height: u32, pending_events: &Mutex)>>, - send_payment_along_path: &SP, + send_payment_along_path: &SP, logger: &WithContext, ) where R::Target: Router, @@ -1689,12 +1700,13 @@ where NS::Target: NodeSigner, IH: Fn() -> InFlightHtlcs, SP: Fn(SendAlongPathArgs) -> Result<(), APIError>, + L::Target: Logger, { match err { PaymentSendFailure::AllFailedResendSafe(errs) => { self.remove_session_privs(payment_id, route.paths.iter().zip(onion_session_privs.iter())); - Self::push_path_failed_evs_and_scids(payment_id, payment_hash, &mut route_params, route.paths, errs.into_iter().map(|e| Err(e)), &self.logger, pending_events); - self.find_route_and_send_payment(payment_hash, payment_id, route_params, router, first_hops, inflight_htlcs, entropy_source, node_signer, best_block_height, pending_events, send_payment_along_path); + Self::push_path_failed_evs_and_scids(payment_id, payment_hash, &mut route_params, route.paths, errs.into_iter().map(|e| Err(e)), pending_events, logger); + self.find_route_and_send_payment(payment_hash, payment_id, route_params, router, first_hops, inflight_htlcs, entropy_source, node_signer, best_block_height, pending_events, send_payment_along_path, logger); }, PaymentSendFailure::PartialFailure { failed_paths_retry: Some(mut retry), results, .. } => { debug_assert_eq!(results.len(), route.paths.len()); @@ -1710,11 +1722,11 @@ where } }); self.remove_session_privs(payment_id, failed_paths); - Self::push_path_failed_evs_and_scids(payment_id, payment_hash, &mut retry, route.paths, results.into_iter(), &self.logger, pending_events); + Self::push_path_failed_evs_and_scids(payment_id, payment_hash, &mut retry, route.paths, results.into_iter(), pending_events, logger); // Some paths were sent, even if we failed to send the full MPP value our recipient may // misbehave and claim the funds, at which point we have to consider the payment sent, so // return `Ok()` here, ignoring any retry errors. - self.find_route_and_send_payment(payment_hash, payment_id, retry, router, first_hops, inflight_htlcs, entropy_source, node_signer, best_block_height, pending_events, send_payment_along_path); + self.find_route_and_send_payment(payment_hash, payment_id, retry, router, first_hops, inflight_htlcs, entropy_source, node_signer, best_block_height, pending_events, send_payment_along_path, logger); }, PaymentSendFailure::PartialFailure { failed_paths_retry: None, .. } => { // This may happen if we send a payment and some paths fail, but only due to a temporary @@ -1722,13 +1734,13 @@ where // initial HTLC-Add messages yet. }, PaymentSendFailure::PathParameterError(results) => { - log_error!(self.logger, "Failed to send to route due to parameter error in a single path. Your router is buggy"); + log_error!(logger, "Failed to send to route due to parameter error in a single path. Your router is buggy"); self.remove_session_privs(payment_id, route.paths.iter().zip(onion_session_privs.iter())); - Self::push_path_failed_evs_and_scids(payment_id, payment_hash, &mut route_params, route.paths, results.into_iter(), &self.logger, pending_events); + Self::push_path_failed_evs_and_scids(payment_id, payment_hash, &mut route_params, route.paths, results.into_iter(), pending_events, logger); self.abandon_payment(payment_id, PaymentFailureReason::UnexpectedError, pending_events); }, PaymentSendFailure::ParameterError(e) => { - log_error!(self.logger, "Failed to send to route due to parameter error: {:?}. Your router is buggy", e); + log_error!(logger, "Failed to send to route due to parameter error: {:?}. Your router is buggy", e); self.remove_session_privs(payment_id, route.paths.iter().zip(onion_session_privs.iter())); self.abandon_payment(payment_id, PaymentFailureReason::UnexpectedError, pending_events); }, @@ -1738,11 +1750,15 @@ where fn push_path_failed_evs_and_scids< I: ExactSizeIterator + Iterator>, + L: Deref, >( payment_id: PaymentId, payment_hash: PaymentHash, route_params: &mut RouteParameters, - paths: Vec, path_results: I, logger: &L, + paths: Vec, path_results: I, pending_events: &Mutex)>>, - ) { + logger: &WithContext, + ) where + L::Target: Logger, + { let mut events = pending_events.lock().unwrap(); debug_assert_eq!(paths.len(), path_results.len()); for (path, path_res) in paths.into_iter().zip(path_results) { @@ -2216,11 +2232,15 @@ where } #[rustfmt::skip] - pub(super) fn claim_htlc( + pub(super) fn claim_htlc( &self, payment_id: PaymentId, payment_preimage: PaymentPreimage, bolt12_invoice: Option, session_priv: SecretKey, path: Path, from_onchain: bool, ev_completion_action: &mut Option, pending_events: &Mutex)>>, - ) { + logger: &WithContext, + ) + where + L::Target: Logger, + { let mut session_priv_bytes = [0; 32]; session_priv_bytes.copy_from_slice(&session_priv[..]); let mut outbounds = self.pending_outbound_payments.lock().unwrap(); @@ -2228,7 +2248,7 @@ where if let hash_map::Entry::Occupied(mut payment) = outbounds.entry(payment_id) { if !payment.get().is_fulfilled() { let payment_hash = PaymentHash(Sha256::hash(&payment_preimage.0).to_byte_array()); - log_info!(self.logger, "Payment with id {} and hash {} sent!", payment_id, payment_hash); + log_info!(logger, "Payment with id {} and hash {} sent!", payment_id, payment_hash); let fee_paid_msat = payment.get().get_pending_fee_msat(); let amount_msat = payment.get().total_msat(); pending_events.push_back((events::Event::PaymentSent { @@ -2258,7 +2278,7 @@ where } } } else { - log_trace!(self.logger, "Received duplicative fulfill for HTLC with payment_preimage {}", &payment_preimage); + log_trace!(logger, "Received duplicative fulfill for HTLC with payment_preimage {}", &payment_preimage); } } @@ -2378,13 +2398,15 @@ where }); } - pub(super) fn fail_htlc( + pub(super) fn fail_htlc( &self, source: &HTLCSource, payment_hash: &PaymentHash, onion_error: &HTLCFailReason, path: &Path, session_priv: &SecretKey, payment_id: &PaymentId, probing_cookie_secret: [u8; 32], secp_ctx: &Secp256k1, pending_events: &Mutex)>>, - completion_action: &mut Option, - ) { + completion_action: &mut Option, logger: &WithContext, + ) where + L::Target: Logger, + { #[cfg(any(test, feature = "_test_utils"))] let DecodedOnionFailure { network_update, @@ -2395,7 +2417,7 @@ where failed_within_blinded_path, hold_times, .. - } = onion_error.decode_onion_failure(secp_ctx, &self.logger, &source); + } = onion_error.decode_onion_failure(secp_ctx, &logger, &source); #[cfg(not(any(test, feature = "_test_utils")))] let DecodedOnionFailure { network_update, @@ -2404,7 +2426,7 @@ where failed_within_blinded_path, hold_times, .. - } = onion_error.decode_onion_failure(secp_ctx, &self.logger, &source); + } = onion_error.decode_onion_failure(secp_ctx, &logger, &source); let payment_is_probe = payment_is_probe(payment_hash, &payment_id, probing_cookie_secret); let mut session_priv_bytes = [0; 32]; @@ -2429,7 +2451,7 @@ where if let hash_map::Entry::Occupied(mut payment) = outbounds.entry(*payment_id) { if !payment.get_mut().remove(&session_priv_bytes, Some(&path)) { log_trace!( - self.logger, + logger, "Received duplicative fail for HTLC with payment_hash {}", &payment_hash ); @@ -2437,7 +2459,7 @@ where } if payment.get().is_fulfilled() { log_trace!( - self.logger, + logger, "Received failure of HTLC with payment_hash {} after payment completion", &payment_hash ); @@ -2485,18 +2507,13 @@ where is_retryable_now } else { log_trace!( - self.logger, - "Received duplicative fail for HTLC with payment_hash {}", - &payment_hash + logger, + "Received duplicative fail for HTLC with payment_hash {payment_hash}" ); return; }; core::mem::drop(outbounds); - log_trace!( - self.logger, - "Failing outbound payment HTLC with payment_hash {}", - &payment_hash - ); + log_trace!(logger, "Failing outbound payment HTLC with payment_hash {payment_hash}"); let path_failure = { if payment_is_probe { @@ -2618,10 +2635,12 @@ where invoice_requests } - pub(super) fn insert_from_monitor_on_startup( + pub(super) fn insert_from_monitor_on_startup( &self, payment_id: PaymentId, payment_hash: PaymentHash, session_priv_bytes: [u8; 32], - path: &Path, best_block_height: u32, - ) { + path: &Path, best_block_height: u32, logger: &WithContext, + ) where + L::Target: Logger, + { let path_amt = path.final_value_msat(); let path_fee = path.fee_msat(); @@ -2670,12 +2689,12 @@ where entry.get_mut().insert(session_priv_bytes, &path) }, }; - log_info!(self.logger, "{} a pending payment path for {} msat for session priv {} on an existing pending payment with payment hash {}", + log_info!(logger, "{} a pending payment path for {} msat for session priv {} on an existing pending payment with payment hash {}", if newly_added { "Added" } else { "Had" }, path_amt, log_bytes!(session_priv_bytes), payment_hash); }, hash_map::Entry::Vacant(entry) => { entry.insert(new_retryable!()); - log_info!(self.logger, "Added a pending payment for {} msat with payment hash {} for path with session priv {}", + log_info!(logger, "Added a pending payment for {} msat with payment hash {} for path with session priv {}", path_amt, payment_hash, log_bytes!(session_priv_bytes)); }, } @@ -2834,6 +2853,7 @@ mod tests { use crate::types::payment::{PaymentHash, PaymentPreimage}; use crate::util::errors::APIError; use crate::util::hash_tables::new_hash_map; + use crate::util::logger::WithContext; use crate::util::test_utils; use alloc::collections::VecDeque; @@ -2871,7 +2891,9 @@ mod tests { #[rustfmt::skip] fn do_fails_paying_after_expiration(on_retry: bool) { let logger = test_utils::TestLogger::new(); - let outbound_payments = OutboundPayments::new(new_hash_map(), &logger); + let logger_ref = &logger; + let log = WithContext::from(&logger_ref, None, None, Some(PaymentHash([0; 32]))); + let outbound_payments = OutboundPayments::new(new_hash_map()); let network_graph = Arc::new(NetworkGraph::new(Network::Testnet, &logger)); let scorer = RwLock::new(test_utils::TestScorer::new()); let router = test_utils::TestRouter::new(network_graph, &logger, &scorer); @@ -2893,7 +2915,7 @@ mod tests { outbound_payments.find_route_and_send_payment( PaymentHash([0; 32]), PaymentId([0; 32]), expired_route_params, &&router, vec![], &|| InFlightHtlcs::new(), &&keys_manager, &&keys_manager, 0, &pending_events, - &|_| Ok(())); + &|_| Ok(()), &log); let events = pending_events.lock().unwrap(); assert_eq!(events.len(), 1); if let Event::PaymentFailed { ref reason, .. } = events[0].0 { @@ -2903,7 +2925,7 @@ mod tests { let err = outbound_payments.send_payment( PaymentHash([0; 32]), RecipientOnionFields::spontaneous_empty(), PaymentId([0; 32]), Retry::Attempts(0), expired_route_params, &&router, vec![], || InFlightHtlcs::new(), - &&keys_manager, &&keys_manager, 0, &pending_events, |_| Ok(())).unwrap_err(); + &&keys_manager, &&keys_manager, 0, &pending_events, |_| Ok(()), &log).unwrap_err(); if let RetryableSendFailure::PaymentExpired = err { } else { panic!("Unexpected error"); } } } @@ -2916,7 +2938,9 @@ mod tests { #[rustfmt::skip] fn do_find_route_error(on_retry: bool) { let logger = test_utils::TestLogger::new(); - let outbound_payments = OutboundPayments::new(new_hash_map(), &logger); + let logger_ref = &logger; + let log = WithContext::from(&logger_ref, None, None, Some(PaymentHash([0; 32]))); + let outbound_payments = OutboundPayments::new(new_hash_map()); let network_graph = Arc::new(NetworkGraph::new(Network::Testnet, &logger)); let scorer = RwLock::new(test_utils::TestScorer::new()); let router = test_utils::TestRouter::new(network_graph, &logger, &scorer); @@ -2937,7 +2961,7 @@ mod tests { outbound_payments.find_route_and_send_payment( PaymentHash([0; 32]), PaymentId([0; 32]), route_params, &&router, vec![], &|| InFlightHtlcs::new(), &&keys_manager, &&keys_manager, 0, &pending_events, - &|_| Ok(())); + &|_| Ok(()), &log); let events = pending_events.lock().unwrap(); assert_eq!(events.len(), 1); if let Event::PaymentFailed { .. } = events[0].0 { } else { panic!("Unexpected event"); } @@ -2945,7 +2969,7 @@ mod tests { let err = outbound_payments.send_payment( PaymentHash([0; 32]), RecipientOnionFields::spontaneous_empty(), PaymentId([0; 32]), Retry::Attempts(0), route_params, &&router, vec![], || InFlightHtlcs::new(), - &&keys_manager, &&keys_manager, 0, &pending_events, |_| Ok(())).unwrap_err(); + &&keys_manager, &&keys_manager, 0, &pending_events, |_| Ok(()), &log).unwrap_err(); if let RetryableSendFailure::RouteNotFound = err { } else { panic!("Unexpected error"); } } @@ -2955,7 +2979,9 @@ mod tests { #[rustfmt::skip] fn initial_send_payment_path_failed_evs() { let logger = test_utils::TestLogger::new(); - let outbound_payments = OutboundPayments::new(new_hash_map(), &logger); + let logger_ref = &logger; + let log = WithContext::from(&logger_ref, None, None, Some(PaymentHash([0; 32]))); + let outbound_payments = OutboundPayments::new(new_hash_map()); let network_graph = Arc::new(NetworkGraph::new(Network::Testnet, &logger)); let scorer = RwLock::new(test_utils::TestScorer::new()); let router = test_utils::TestRouter::new(network_graph, &logger, &scorer); @@ -2995,7 +3021,7 @@ mod tests { PaymentHash([0; 32]), RecipientOnionFields::spontaneous_empty(), 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() })).unwrap(); + |_| Err(APIError::ChannelUnavailable { err: "test".to_owned() }), &log).unwrap(); let mut events = pending_events.lock().unwrap(); assert_eq!(events.len(), 2); if let Event::PaymentPathFailed { @@ -3013,7 +3039,7 @@ mod tests { PaymentHash([0; 32]), RecipientOnionFields::spontaneous_empty(), PaymentId([0; 32]), Retry::Attempts(0), route_params.clone(), &&router, vec![], || InFlightHtlcs::new(), &&keys_manager, &&keys_manager, 0, &pending_events, - |_| Err(APIError::MonitorUpdateInProgress)).unwrap(); + |_| Err(APIError::MonitorUpdateInProgress), &log).unwrap(); assert_eq!(pending_events.lock().unwrap().len(), 0); // Ensure that any other error will result in a PaymentPathFailed event but no blamed scid. @@ -3021,7 +3047,7 @@ mod tests { PaymentHash([0; 32]), RecipientOnionFields::spontaneous_empty(), 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() })).unwrap(); + |_| Err(APIError::APIMisuseError { err: "test".to_owned() }), &log).unwrap(); let events = pending_events.lock().unwrap(); assert_eq!(events.len(), 2); if let Event::PaymentPathFailed { @@ -3037,8 +3063,7 @@ mod tests { #[rustfmt::skip] fn removes_stale_awaiting_invoice_using_absolute_timeout() { let pending_events = Mutex::new(VecDeque::new()); - let logger = test_utils::TestLogger::new(); - let outbound_payments = OutboundPayments::new(new_hash_map(), &logger); + let outbound_payments = OutboundPayments::new(new_hash_map()); let payment_id = PaymentId([0; 32]); let absolute_expiry = 100; let tick_interval = 10; @@ -3093,8 +3118,7 @@ mod tests { #[rustfmt::skip] fn removes_stale_awaiting_invoice_using_timer_ticks() { let pending_events = Mutex::new(VecDeque::new()); - let logger = test_utils::TestLogger::new(); - let outbound_payments = OutboundPayments::new(new_hash_map(), &logger); + let outbound_payments = OutboundPayments::new(new_hash_map()); let payment_id = PaymentId([0; 32]); let timer_ticks = 3; let expiration = StaleExpiration::TimerTicks(timer_ticks); @@ -3148,8 +3172,7 @@ mod tests { #[rustfmt::skip] fn removes_abandoned_awaiting_invoice() { let pending_events = Mutex::new(VecDeque::new()); - let logger = test_utils::TestLogger::new(); - let outbound_payments = OutboundPayments::new(new_hash_map(), &logger); + let outbound_payments = OutboundPayments::new(new_hash_map()); let payment_id = PaymentId([0; 32]); let expiration = StaleExpiration::AbsoluteTimeout(Duration::from_secs(100)); @@ -3180,6 +3203,8 @@ mod tests { #[rustfmt::skip] fn fails_sending_payment_for_expired_bolt12_invoice() { let logger = test_utils::TestLogger::new(); + let logger_ref = &logger; + let log = WithContext::from(&logger_ref, None, None, Some(PaymentHash([0; 32]))); let network_graph = Arc::new(NetworkGraph::new(Network::Testnet, &logger)); let scorer = RwLock::new(test_utils::TestScorer::new()); let router = test_utils::TestRouter::new(network_graph, &logger, &scorer); @@ -3189,7 +3214,7 @@ mod tests { let nonce = Nonce([0; 16]); let pending_events = Mutex::new(VecDeque::new()); - let outbound_payments = OutboundPayments::new(new_hash_map(), &logger); + let outbound_payments = OutboundPayments::new(new_hash_map()); let payment_id = PaymentId([0; 32]); let expiration = StaleExpiration::AbsoluteTimeout(Duration::from_secs(100)); @@ -3214,7 +3239,7 @@ mod tests { outbound_payments.send_payment_for_bolt12_invoice( &invoice, payment_id, &&router, vec![], Bolt12InvoiceFeatures::empty(), || InFlightHtlcs::new(), &&keys_manager, &&keys_manager, &EmptyNodeIdLookUp {}, - &secp_ctx, 0, &pending_events, |_| panic!() + &secp_ctx, 0, &pending_events, |_| panic!(), &log ), Err(Bolt12PaymentError::SendingFailed(RetryableSendFailure::PaymentExpired)), ); @@ -3235,6 +3260,8 @@ mod tests { #[rustfmt::skip] fn fails_finding_route_for_bolt12_invoice() { let logger = test_utils::TestLogger::new(); + let logger_ref = &logger; + let log = WithContext::from(&logger_ref, None, None, Some(PaymentHash([0; 32]))); let network_graph = Arc::new(NetworkGraph::new(Network::Testnet, &logger)); let scorer = RwLock::new(test_utils::TestScorer::new()); let router = test_utils::TestRouter::new(network_graph, &logger, &scorer); @@ -3242,7 +3269,7 @@ mod tests { let keys_manager = test_utils::TestKeysInterface::new(&[0; 32], Network::Testnet); let pending_events = Mutex::new(VecDeque::new()); - let outbound_payments = OutboundPayments::new(new_hash_map(), &logger); + let outbound_payments = OutboundPayments::new(new_hash_map()); let expanded_key = ExpandedKey::new([42; 32]); let nonce = Nonce([0; 16]); let payment_id = PaymentId([0; 32]); @@ -3277,7 +3304,7 @@ mod tests { outbound_payments.send_payment_for_bolt12_invoice( &invoice, payment_id, &&router, vec![], Bolt12InvoiceFeatures::empty(), || InFlightHtlcs::new(), &&keys_manager, &&keys_manager, &EmptyNodeIdLookUp {}, - &secp_ctx, 0, &pending_events, |_| panic!() + &secp_ctx, 0, &pending_events, |_| panic!(), &log ), Err(Bolt12PaymentError::SendingFailed(RetryableSendFailure::RouteNotFound)), ); @@ -3298,6 +3325,8 @@ mod tests { #[rustfmt::skip] fn sends_payment_for_bolt12_invoice() { let logger = test_utils::TestLogger::new(); + let logger_ref = &logger; + let log = WithContext::from(&logger_ref, None, None, Some(PaymentHash([0; 32]))); let network_graph = Arc::new(NetworkGraph::new(Network::Testnet, &logger)); let scorer = RwLock::new(test_utils::TestScorer::new()); let router = test_utils::TestRouter::new(network_graph, &logger, &scorer); @@ -3305,7 +3334,7 @@ mod tests { let keys_manager = test_utils::TestKeysInterface::new(&[0; 32], Network::Testnet); let pending_events = Mutex::new(VecDeque::new()); - let outbound_payments = OutboundPayments::new(new_hash_map(), &logger); + let outbound_payments = OutboundPayments::new(new_hash_map()); let expanded_key = ExpandedKey::new([42; 32]); let nonce = Nonce([0; 16]); let payment_id = PaymentId([0; 32]); @@ -3353,7 +3382,7 @@ mod tests { outbound_payments.send_payment_for_bolt12_invoice( &invoice, payment_id, &&router, vec![], Bolt12InvoiceFeatures::empty(), || InFlightHtlcs::new(), &&keys_manager, &&keys_manager, &EmptyNodeIdLookUp {}, - &secp_ctx, 0, &pending_events, |_| panic!() + &secp_ctx, 0, &pending_events, |_| panic!(), &log ), Err(Bolt12PaymentError::UnexpectedInvoice), ); @@ -3373,7 +3402,7 @@ mod tests { outbound_payments.send_payment_for_bolt12_invoice( &invoice, payment_id, &&router, vec![], Bolt12InvoiceFeatures::empty(), || InFlightHtlcs::new(), &&keys_manager, &&keys_manager, &EmptyNodeIdLookUp {}, - &secp_ctx, 0, &pending_events, |_| Ok(()) + &secp_ctx, 0, &pending_events, |_| Ok(()), &log ), Ok(()), ); @@ -3384,7 +3413,7 @@ mod tests { outbound_payments.send_payment_for_bolt12_invoice( &invoice, payment_id, &&router, vec![], Bolt12InvoiceFeatures::empty(), || InFlightHtlcs::new(), &&keys_manager, &&keys_manager, &EmptyNodeIdLookUp {}, - &secp_ctx, 0, &pending_events, |_| panic!() + &secp_ctx, 0, &pending_events, |_| panic!(), &log ), Err(Bolt12PaymentError::DuplicateInvoice), ); @@ -3413,8 +3442,7 @@ mod tests { #[rustfmt::skip] fn time_out_unreleased_async_payments() { let pending_events = Mutex::new(VecDeque::new()); - let logger = test_utils::TestLogger::new(); - let outbound_payments = OutboundPayments::new(new_hash_map(), &logger); + let outbound_payments = OutboundPayments::new(new_hash_map()); let payment_id = PaymentId([0; 32]); let absolute_expiry = 60; @@ -3464,8 +3492,7 @@ mod tests { #[rustfmt::skip] fn abandon_unreleased_async_payment() { let pending_events = Mutex::new(VecDeque::new()); - let logger = test_utils::TestLogger::new(); - let outbound_payments = OutboundPayments::new(new_hash_map(), &logger); + let outbound_payments = OutboundPayments::new(new_hash_map()); let payment_id = PaymentId([0; 32]); let absolute_expiry = 60; From e4fa55883947428e8852b120486fa2f5bf415a6c Mon Sep 17 00:00:00 2001 From: Matt Corallo Date: Thu, 11 Dec 2025 15:52:25 +0000 Subject: [PATCH 012/135] Allow clippy's new assertions-on-constants lint This is really dumb, `assert!(cfg!(fuzzing))` is a perfectly reasonable thing to write! Backport of 6ff720b9f9b9fed39a951237a25675295ef50258 --- ci/check-lint.sh | 1 + 1 file changed, 1 insertion(+) diff --git a/ci/check-lint.sh b/ci/check-lint.sh index bd4df3b85b8..11c6f083dd0 100755 --- a/ci/check-lint.sh +++ b/ci/check-lint.sh @@ -13,6 +13,7 @@ CLIPPY() { -A clippy::unwrap-or-default \ -A clippy::upper_case_acronyms \ -A clippy::swap-with-temporary \ + -A clippy::assertions-on-constants \ `# Things where we do odd stuff on purpose ` \ -A clippy::unusual_byte_groupings \ -A clippy::unit_arg \ From 940a3d9fe91d0e2379ef0cf9762e1a51d0e6267e Mon Sep 17 00:00:00 2001 From: Matt Corallo Date: Mon, 26 Jan 2026 00:27:41 +0000 Subject: [PATCH 013/135] Update CI pins for new MSRV breakage --- .github/workflows/build.yml | 4 ++++ ci/ci-tests.sh | 17 +++++++++++++++++ 2 files changed, 21 insertions(+) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 6db02669e3c..16c6c5b4f08 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -264,7 +264,11 @@ jobs: - name: Pin the syn and regex dependencies run: | cd fuzz && cargo update -p regex --precise "1.9.6" && cargo update -p syn --precise "2.0.106" && cargo update -p quote --precise "1.0.41" + cargo update -p proc-macro2 --precise "1.0.103" --verbose && cargo update -p serde_json --precise "1.0.145" --verbose + cargo update -p itoa --precise "1.0.15" --verbose && cargo update -p ryu --precise "1.0.20" --verbose cd write-seeds && cargo update -p regex --precise "1.9.6" && cargo update -p syn --precise "2.0.106" && cargo update -p quote --precise "1.0.41" + cargo update -p proc-macro2 --precise "1.0.103" --verbose && cargo update -p serde_json --precise "1.0.145" --verbose + cargo update -p itoa --precise "1.0.15" --verbose && cargo update -p ryu --precise "1.0.20" --verbose - name: Sanity check fuzz targets on Rust ${{ env.TOOLCHAIN }} run: | cd fuzz diff --git a/ci/ci-tests.sh b/ci/ci-tests.sh index f3ecc72806a..eb7dfd58826 100755 --- a/ci/ci-tests.sh +++ b/ci/ci-tests.sh @@ -17,6 +17,18 @@ function PIN_RELEASE_DEPS { # quote 1.0.42 requires rustc 1.68.0 [ "$RUSTC_MINOR_VERSION" -lt 68 ] && cargo update -p quote --precise "1.0.41" --verbose + # Starting with version 1.0.104, the `proc-macro2` crate has an MSRV of rustc 1.68 + [ "$RUSTC_MINOR_VERSION" -lt 68 ] && cargo update -p proc-macro2 --precise "1.0.103" --verbose + + # Starting with version 1.0.146, the `serde_json` crate has an MSRV of rustc 1.68 + [ "$RUSTC_MINOR_VERSION" -lt 68 ] && cargo update -p serde_json --precise "1.0.145" --verbose + + # Starting with version 1.0.16, the `itoa` crate has an MSRV of rustc 1.68 + [ "$RUSTC_MINOR_VERSION" -lt 68 ] && cargo update -p itoa --precise "1.0.15" --verbose + + # Starting with version 1.0.21, the `ryu` crate has an MSRV of rustc 1.68 + [ "$RUSTC_MINOR_VERSION" -lt 68 ] && cargo update -p ryu --precise "1.0.20" --verbose + return 0 # Don't fail the script if our rustc is higher than the last check } @@ -58,6 +70,7 @@ pushd lightning-tests [ "$RUSTC_MINOR_VERSION" -lt 65 ] && cargo update -p regex --precise "1.9.6" --verbose [ "$RUSTC_MINOR_VERSION" -lt 68 ] && cargo update -p syn --precise "2.0.106" --verbose [ "$RUSTC_MINOR_VERSION" -lt 68 ] && cargo update -p quote --precise "1.0.41" --verbose +[ "$RUSTC_MINOR_VERSION" -lt 68 ] && cargo update -p proc-macro2 --precise "1.0.103" --verbose cargo test popd @@ -130,6 +143,10 @@ echo -e "\n\nTesting no_std build on a downstream no-std crate" pushd no-std-check [ "$RUSTC_MINOR_VERSION" -lt 68 ] && cargo update -p syn --precise "2.0.106" --verbose [ "$RUSTC_MINOR_VERSION" -lt 68 ] && cargo update -p quote --precise "1.0.41" --verbose +[ "$RUSTC_MINOR_VERSION" -lt 68 ] && cargo update -p proc-macro2 --precise "1.0.103" --verbose +[ "$RUSTC_MINOR_VERSION" -lt 68 ] && cargo update -p serde_json --precise "1.0.145" --verbose +[ "$RUSTC_MINOR_VERSION" -lt 68 ] && cargo update -p itoa --precise "1.0.15" --verbose +[ "$RUSTC_MINOR_VERSION" -lt 68 ] && cargo update -p ryu --precise "1.0.20" --verbose cargo check --verbose --color always [ "$CI_MINIMIZE_DISK_USAGE" != "" ] && cargo clean popd From 8857d70c4493f7cb73389c67ad6d018cd9ca34ca Mon Sep 17 00:00:00 2001 From: Matt Corallo Date: Sun, 25 Jan 2026 22:31:42 +0000 Subject: [PATCH 014/135] Bump `lightning{,-macros,-transaction-sync}` to 0.2.1 --- lightning-macros/Cargo.toml | 2 +- lightning-transaction-sync/Cargo.toml | 2 +- lightning/Cargo.toml | 6 +++--- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/lightning-macros/Cargo.toml b/lightning-macros/Cargo.toml index 546c4de5129..a7a5eabf589 100644 --- a/lightning-macros/Cargo.toml +++ b/lightning-macros/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "lightning-macros" -version = "0.2.0" +version = "0.2.1" authors = ["Elias Rohrer"] license = "MIT OR Apache-2.0" repository = "https://github.com/lightningdevkit/rust-lightning/" diff --git a/lightning-transaction-sync/Cargo.toml b/lightning-transaction-sync/Cargo.toml index 2e6a99810ff..30908cabdb7 100644 --- a/lightning-transaction-sync/Cargo.toml +++ b/lightning-transaction-sync/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "lightning-transaction-sync" -version = "0.2.0" +version = "0.2.1" authors = ["Elias Rohrer"] license = "MIT OR Apache-2.0" repository = "https://github.com/lightningdevkit/rust-lightning" diff --git a/lightning/Cargo.toml b/lightning/Cargo.toml index 7aa869a18bb..55e4b40144f 100644 --- a/lightning/Cargo.toml +++ b/lightning/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "lightning" -version = "0.2.0" +version = "0.2.1" authors = ["Matt Corallo"] license = "MIT OR Apache-2.0" repository = "https://github.com/lightningdevkit/rust-lightning/" @@ -35,7 +35,7 @@ default = ["std", "grind_signatures"] [dependencies] lightning-types = { version = "0.3.0", path = "../lightning-types", default-features = false } lightning-invoice = { version = "0.34.0", path = "../lightning-invoice", default-features = false } -lightning-macros = { version = "0.2.0", path = "../lightning-macros" } +lightning-macros = { version = "0.2.1", path = "../lightning-macros" } bech32 = { version = "0.11.0", default-features = false } bitcoin = { version = "0.32.2", default-features = false, features = ["secp-recovery"] } @@ -53,7 +53,7 @@ inventory = { version = "0.3", optional = true } [dev-dependencies] regex = "1.5.6" lightning-types = { version = "0.3.0", path = "../lightning-types", features = ["_test_utils"] } -lightning-macros = { version = "0.2.0", path = "../lightning-macros" } +lightning-macros = { version = "0.2.1", path = "../lightning-macros" } parking_lot = { version = "0.12", default-features = false } [dev-dependencies.bitcoin] From 45a9d2be6560499e38bc3aa6b8c8f6e63e337efc Mon Sep 17 00:00:00 2001 From: Matt Corallo Date: Sun, 25 Jan 2026 22:34:46 +0000 Subject: [PATCH 015/135] Add CHANGELOG entry for 0.2.1 --- CHANGELOG.md | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index a51c5fda8bd..b8d85210db9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,22 @@ +# 0.2.1 - Jan 26, 2025 - "Electrum Confirmations Logged" + +## API Updates + * The `AttributionData` struct is now public, correcting an issue where it was + accidentally sealed preventing construction of some messages (#4268). + * The async background processor now exits even if work remains to be done as + soon as the sleeper returns the exit flag (#4259). + +## Bug Fixes + * The presence of unconfirmed transactions no longer causes + `ElectrumSyncClient` to spuriously fail to sync (#4341). + * `ChannelManager::splice_channel` now properly fails immediately if the + peer does not support splicing (#4262, #4274). + * A spurious debug assertion was removed which could fail in cases where an + HTLC fails to be forwarded after being accepted (#4312). + * Many log calls related to outbound payments were corrected to include a + `payment_hash` field (#4342). + + # 0.2 - Dec 2, 2025 - "Natively Asynchronous Splicing" ## API Updates From 2933bdbc76459b69a3dd20f68ac42b79295a8953 Mon Sep 17 00:00:00 2001 From: Matt Corallo Date: Fri, 30 Jan 2026 00:03:22 +0000 Subject: [PATCH 016/135] Correct relase date for 0.2.1 --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b8d85210db9..f2794da06e7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,4 @@ -# 0.2.1 - Jan 26, 2025 - "Electrum Confirmations Logged" +# 0.2.1 - Jan 29, 2025 - "Electrum Confirmations Logged" ## API Updates * The `AttributionData` struct is now public, correcting an issue where it was From b23bbea6018d23e530722a2d09a2a9ea87a4b32f Mon Sep 17 00:00:00 2001 From: Wilmer Paulino Date: Thu, 5 Feb 2026 17:12:01 -0800 Subject: [PATCH 017/135] Make get_latest_mon_update_id a helper on TestChainMonitor Backport of 60b5d66e58f5ce7d5ce13a7bb873ad6ff6374a3e Conflicts resolved in: * lightning/src/ln/chanmon_update_fail_tests.rs --- lightning/src/ln/chanmon_update_fail_tests.rs | 73 +++++++++---------- lightning/src/util/test_utils.rs | 5 ++ 2 files changed, 38 insertions(+), 40 deletions(-) diff --git a/lightning/src/ln/chanmon_update_fail_tests.rs b/lightning/src/ln/chanmon_update_fail_tests.rs index 96ae8f922ac..cf3d2aa1c1c 100644 --- a/lightning/src/ln/chanmon_update_fail_tests.rs +++ b/lightning/src/ln/chanmon_update_fail_tests.rs @@ -48,13 +48,6 @@ use crate::prelude::*; use crate::sync::{Arc, Mutex}; use bitcoin::hashes::Hash; -fn get_latest_mon_update_id<'a, 'b, 'c>( - node: &Node<'a, 'b, 'c>, channel_id: ChannelId, -) -> (u64, u64) { - let monitor_id_state = node.chain_monitor.latest_monitor_update_id.lock().unwrap(); - monitor_id_state.get(&channel_id).unwrap().clone() -} - #[test] fn test_monitor_and_persister_update_fail() { // Test that if both updating the `ChannelMonitor` and persisting the updated @@ -212,7 +205,7 @@ fn do_test_simple_monitor_temporary_update_fail(disconnect: bool) { } chanmon_cfgs[0].persister.set_update_ret(ChannelMonitorUpdateStatus::Completed); - let (latest_update, _) = get_latest_mon_update_id(&nodes[0], channel_id); + let (latest_update, _) = nodes[0].chain_monitor.get_latest_mon_update_id(channel_id); nodes[0].chain_monitor.chain_monitor.force_channel_monitor_updated(channel_id, latest_update); check_added_monitors!(nodes[0], 0); @@ -404,7 +397,7 @@ fn do_test_monitor_temporary_update_fail(disconnect_count: usize) { // Now fix monitor updating... chanmon_cfgs[0].persister.set_update_ret(ChannelMonitorUpdateStatus::Completed); - let (latest_update, _) = get_latest_mon_update_id(&nodes[0], channel_id); + let (latest_update, _) = nodes[0].chain_monitor.get_latest_mon_update_id(channel_id); nodes[0].chain_monitor.chain_monitor.force_channel_monitor_updated(channel_id, latest_update); check_added_monitors!(nodes[0], 0); @@ -757,7 +750,7 @@ fn test_monitor_update_fail_cs() { assert!(nodes[1].node.get_and_clear_pending_msg_events().is_empty()); chanmon_cfgs[1].persister.set_update_ret(ChannelMonitorUpdateStatus::Completed); - let (latest_update, _) = get_latest_mon_update_id(&nodes[1], channel_id); + let (latest_update, _) = nodes[1].chain_monitor.get_latest_mon_update_id(channel_id); nodes[1].chain_monitor.chain_monitor.force_channel_monitor_updated(channel_id, latest_update); check_added_monitors!(nodes[1], 0); let responses = nodes[1].node.get_and_clear_pending_msg_events(); @@ -792,7 +785,7 @@ fn test_monitor_update_fail_cs() { } chanmon_cfgs[0].persister.set_update_ret(ChannelMonitorUpdateStatus::Completed); - let (latest_update, _) = get_latest_mon_update_id(&nodes[0], channel_id); + let (latest_update, _) = nodes[0].chain_monitor.get_latest_mon_update_id(channel_id); nodes[0].chain_monitor.chain_monitor.force_channel_monitor_updated(channel_id, latest_update); check_added_monitors!(nodes[0], 0); @@ -868,7 +861,7 @@ fn test_monitor_update_fail_no_rebroadcast() { check_added_monitors!(nodes[1], 1); chanmon_cfgs[1].persister.set_update_ret(ChannelMonitorUpdateStatus::Completed); - let (latest_update, _) = get_latest_mon_update_id(&nodes[1], channel_id); + let (latest_update, _) = nodes[1].chain_monitor.get_latest_mon_update_id(channel_id); nodes[1].chain_monitor.chain_monitor.force_channel_monitor_updated(channel_id, latest_update); assert!(nodes[1].node.get_and_clear_pending_msg_events().is_empty()); check_added_monitors!(nodes[1], 0); @@ -938,7 +931,7 @@ fn test_monitor_update_raa_while_paused() { assert!(nodes[0].node.get_and_clear_pending_msg_events().is_empty()); check_added_monitors!(nodes[0], 1); - let (latest_update, _) = get_latest_mon_update_id(&nodes[0], channel_id); + let (latest_update, _) = nodes[0].chain_monitor.get_latest_mon_update_id(channel_id); nodes[0].chain_monitor.chain_monitor.force_channel_monitor_updated(channel_id, latest_update); check_added_monitors!(nodes[0], 0); @@ -1080,7 +1073,7 @@ fn do_test_monitor_update_fail_raa(test_ignore_second_cs: bool) { // Restore monitor updating, ensuring we immediately get a fail-back update and a // update_add update. chanmon_cfgs[1].persister.set_update_ret(ChannelMonitorUpdateStatus::Completed); - let (latest_update, _) = get_latest_mon_update_id(&nodes[1], chan_2.2); + let (latest_update, _) = nodes[1].chain_monitor.get_latest_mon_update_id(chan_2.2); nodes[1].chain_monitor.chain_monitor.force_channel_monitor_updated(chan_2.2, latest_update); check_added_monitors!(nodes[1], 0); expect_and_process_pending_htlcs_and_htlc_handling_failed( @@ -1354,7 +1347,7 @@ fn test_monitor_update_fail_reestablish() { assert_eq!(bs_channel_upd.contents.channel_flags & 2, 0); chanmon_cfgs[1].persister.set_update_ret(ChannelMonitorUpdateStatus::Completed); - let (latest_update, _) = get_latest_mon_update_id(&nodes[1], chan_1.2); + let (latest_update, _) = nodes[1].chain_monitor.get_latest_mon_update_id(chan_1.2); nodes[1].chain_monitor.chain_monitor.force_channel_monitor_updated(chan_1.2, latest_update); check_added_monitors!(nodes[1], 0); @@ -1439,7 +1432,7 @@ fn raa_no_response_awaiting_raa_state() { assert!(nodes[1].node.get_and_clear_pending_msg_events().is_empty()); check_added_monitors!(nodes[1], 1); - let (latest_update, _) = get_latest_mon_update_id(&nodes[1], channel_id); + let (latest_update, _) = nodes[1].chain_monitor.get_latest_mon_update_id(channel_id); nodes[1].chain_monitor.chain_monitor.force_channel_monitor_updated(channel_id, latest_update); // nodes[1] should be AwaitingRAA here! check_added_monitors!(nodes[1], 0); @@ -1568,7 +1561,7 @@ fn claim_while_disconnected_monitor_update_fail() { // Now un-fail the monitor, which will result in B sending its original commitment update, // receiving the commitment update from A, and the resulting commitment dances. chanmon_cfgs[1].persister.set_update_ret(ChannelMonitorUpdateStatus::Completed); - let (latest_update, _) = get_latest_mon_update_id(&nodes[1], channel_id); + let (latest_update, _) = nodes[1].chain_monitor.get_latest_mon_update_id(channel_id); nodes[1].chain_monitor.chain_monitor.force_channel_monitor_updated(channel_id, latest_update); check_added_monitors!(nodes[1], 0); @@ -1697,7 +1690,7 @@ fn monitor_failed_no_reestablish_response() { get_event_msg!(nodes[0], MessageSendEvent::SendChannelUpdate, node_b_id); chanmon_cfgs[1].persister.set_update_ret(ChannelMonitorUpdateStatus::Completed); - let (latest_update, _) = get_latest_mon_update_id(&nodes[1], channel_id); + let (latest_update, _) = nodes[1].chain_monitor.get_latest_mon_update_id(channel_id); nodes[1].chain_monitor.chain_monitor.force_channel_monitor_updated(channel_id, latest_update); check_added_monitors!(nodes[1], 0); let bs_responses = get_revoke_commit_msgs!(nodes[1], node_a_id); @@ -1795,7 +1788,7 @@ fn first_message_on_recv_ordering() { assert!(nodes[1].node.get_and_clear_pending_msg_events().is_empty()); chanmon_cfgs[1].persister.set_update_ret(ChannelMonitorUpdateStatus::Completed); - let (latest_update, _) = get_latest_mon_update_id(&nodes[1], channel_id); + let (latest_update, _) = nodes[1].chain_monitor.get_latest_mon_update_id(channel_id); nodes[1].chain_monitor.chain_monitor.force_channel_monitor_updated(channel_id, latest_update); check_added_monitors!(nodes[1], 0); @@ -1894,7 +1887,7 @@ fn test_monitor_update_fail_claim() { // Now restore monitor updating on the 0<->1 channel and claim the funds on B. let channel_id = chan_1.2; - let (latest_update, _) = get_latest_mon_update_id(&nodes[1], channel_id); + let (latest_update, _) = nodes[1].chain_monitor.get_latest_mon_update_id(channel_id); nodes[1].chain_monitor.chain_monitor.force_channel_monitor_updated(channel_id, latest_update); expect_payment_claimed!(nodes[1], payment_hash_1, 1_000_000); check_added_monitors!(nodes[1], 0); @@ -2022,7 +2015,7 @@ fn test_monitor_update_on_pending_forwards() { check_added_monitors!(nodes[1], 1); chanmon_cfgs[1].persister.set_update_ret(ChannelMonitorUpdateStatus::Completed); - let (latest_update, _) = get_latest_mon_update_id(&nodes[1], chan_1.2); + let (latest_update, _) = nodes[1].chain_monitor.get_latest_mon_update_id(chan_1.2); nodes[1].chain_monitor.chain_monitor.force_channel_monitor_updated(chan_1.2, latest_update); check_added_monitors!(nodes[1], 0); @@ -2093,7 +2086,7 @@ fn monitor_update_claim_fail_no_response() { assert!(nodes[1].node.get_and_clear_pending_msg_events().is_empty()); chanmon_cfgs[1].persister.set_update_ret(ChannelMonitorUpdateStatus::Completed); - let (latest_update, _) = get_latest_mon_update_id(&nodes[1], channel_id); + let (latest_update, _) = nodes[1].chain_monitor.get_latest_mon_update_id(channel_id); nodes[1].chain_monitor.chain_monitor.force_channel_monitor_updated(channel_id, latest_update); expect_payment_claimed!(nodes[1], payment_hash_1, 1_000_000); check_added_monitors!(nodes[1], 0); @@ -2165,7 +2158,7 @@ fn do_during_funding_monitor_fail( assert!(nodes[0].node.get_and_clear_pending_msg_events().is_empty()); assert!(nodes[0].node.get_and_clear_pending_events().is_empty()); chanmon_cfgs[0].persister.set_update_ret(ChannelMonitorUpdateStatus::Completed); - let (latest_update, _) = get_latest_mon_update_id(&nodes[0], channel_id); + let (latest_update, _) = nodes[0].chain_monitor.get_latest_mon_update_id(channel_id); nodes[0].chain_monitor.chain_monitor.force_channel_monitor_updated(channel_id, latest_update); check_added_monitors!(nodes[0], 0); expect_channel_pending_event(&nodes[0], &node_b_id); @@ -2220,7 +2213,7 @@ fn do_during_funding_monitor_fail( } chanmon_cfgs[1].persister.set_update_ret(ChannelMonitorUpdateStatus::Completed); - let (latest_update, _) = get_latest_mon_update_id(&nodes[1], channel_id); + let (latest_update, _) = nodes[1].chain_monitor.get_latest_mon_update_id(channel_id); nodes[1].chain_monitor.chain_monitor.force_channel_monitor_updated(channel_id, latest_update); check_added_monitors!(nodes[1], 0); @@ -2338,7 +2331,7 @@ fn test_path_paused_mpp() { // And check that, after we successfully update the monitor for chan_2 we can pass the second // HTLC along to nodes[3] and claim the whole payment back to nodes[0]. - let (latest_update, _) = get_latest_mon_update_id(&nodes[0], chan_2_id); + let (latest_update, _) = nodes[0].chain_monitor.get_latest_mon_update_id(chan_2_id); nodes[0].chain_monitor.chain_monitor.force_channel_monitor_updated(chan_2_id, latest_update); let mut events = nodes[0].node.get_and_clear_pending_msg_events(); @@ -2784,7 +2777,7 @@ fn do_channel_holding_cell_serialize(disconnect: bool, reload_a: bool) { // If we finish updating the monitor, we should free the holding cell right away (this did // not occur prior to #756). chanmon_cfgs[0].persister.set_update_ret(ChannelMonitorUpdateStatus::Completed); - let (mon_id, _) = get_latest_mon_update_id(&nodes[0], chan_id); + let (mon_id, _) = nodes[0].chain_monitor.get_latest_mon_update_id(chan_id); nodes[0].chain_monitor.chain_monitor.force_channel_monitor_updated(chan_id, mon_id); expect_payment_claimed!(nodes[0], payment_hash_0, 100_000); @@ -3049,7 +3042,7 @@ fn test_temporary_error_during_shutdown() { chanmon_cfgs[0].persister.set_update_ret(ChannelMonitorUpdateStatus::Completed); chanmon_cfgs[1].persister.set_update_ret(ChannelMonitorUpdateStatus::Completed); - let (latest_update, _) = get_latest_mon_update_id(&nodes[0], channel_id); + let (latest_update, _) = nodes[0].chain_monitor.get_latest_mon_update_id(channel_id); nodes[0].chain_monitor.chain_monitor.force_channel_monitor_updated(channel_id, latest_update); nodes[1].node.handle_closing_signed( node_a_id, @@ -3059,7 +3052,7 @@ fn test_temporary_error_during_shutdown() { assert!(nodes[1].node.get_and_clear_pending_msg_events().is_empty()); chanmon_cfgs[1].persister.set_update_ret(ChannelMonitorUpdateStatus::Completed); - let (latest_update, _) = get_latest_mon_update_id(&nodes[1], channel_id); + let (latest_update, _) = nodes[1].chain_monitor.get_latest_mon_update_id(channel_id); nodes[1].chain_monitor.chain_monitor.force_channel_monitor_updated(channel_id, latest_update); nodes[0].node.handle_closing_signed( @@ -3104,8 +3097,8 @@ fn double_temp_error() { chanmon_cfgs[1].persister.set_update_ret(ChannelMonitorUpdateStatus::InProgress); // `claim_funds` results in a ChannelMonitorUpdate. nodes[1].node.claim_funds(payment_preimage_1); - check_added_monitors!(nodes[1], 1); - let (latest_update_1, _) = get_latest_mon_update_id(&nodes[1], channel_id); + check_added_monitors(&nodes[1], 1); + let (latest_update_1, _) = nodes[1].chain_monitor.get_latest_mon_update_id(channel_id); chanmon_cfgs[1].persister.set_update_ret(ChannelMonitorUpdateStatus::InProgress); // Previously, this would've panicked due to a double-call to `Channel::monitor_update_failed`, @@ -3114,7 +3107,7 @@ fn double_temp_error() { check_added_monitors!(nodes[1], 1); chanmon_cfgs[1].persister.set_update_ret(ChannelMonitorUpdateStatus::Completed); - let (latest_update_2, _) = get_latest_mon_update_id(&nodes[1], channel_id); + let (latest_update_2, _) = nodes[1].chain_monitor.get_latest_mon_update_id(channel_id); nodes[1].chain_monitor.chain_monitor.force_channel_monitor_updated(channel_id, latest_update_1); assert!(nodes[1].node.get_and_clear_pending_msg_events().is_empty()); check_added_monitors!(nodes[1], 0); @@ -3521,7 +3514,7 @@ fn do_test_blocked_chan_preimage_release(completion_mode: BlockedUpdateComplMode reconnect_nodes(a_b_reconnect); reconnect_nodes(ReconnectArgs::new(&nodes[2], &nodes[1])); } else if completion_mode == BlockedUpdateComplMode::Async { - let (latest_update, _) = get_latest_mon_update_id(&nodes[1], chan_id_2); + let (latest_update, _) = nodes[1].chain_monitor.get_latest_mon_update_id(chan_id_2); nodes[1] .chain_monitor .chain_monitor @@ -3701,7 +3694,7 @@ fn do_test_inverted_mon_completion_order( // (Finally) complete the A <-> B ChannelMonitorUpdate, ensuring the preimage is durably on // disk in the proper ChannelMonitor, unblocking the B <-> C ChannelMonitor updating // process. - let (_, ab_update_id) = get_latest_mon_update_id(&nodes[1], chan_id_ab); + let (_, ab_update_id) = nodes[1].chain_monitor.get_latest_mon_update_id(chan_id_ab); nodes[1] .chain_monitor .chain_monitor @@ -3734,7 +3727,7 @@ fn do_test_inverted_mon_completion_order( // ChannelMonitorUpdate hasn't yet completed. reconnect_nodes(ReconnectArgs::new(&nodes[0], &nodes[1])); - let (_, ab_update_id) = get_latest_mon_update_id(&nodes[1], chan_id_ab); + let (_, ab_update_id) = nodes[1].chain_monitor.get_latest_mon_update_id(chan_id_ab); nodes[1] .chain_monitor .chain_monitor @@ -3947,7 +3940,7 @@ fn do_test_durable_preimages_on_closed_channel( // Once the blocked `ChannelMonitorUpdate` *finally* completes, the pending // `PaymentForwarded` event will finally be released. - let (_, ab_update_id) = get_latest_mon_update_id(&nodes[1], chan_id_ab); + 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 @@ -4059,7 +4052,7 @@ fn do_test_reload_mon_update_completion_actions(close_during_reload: bool) { mine_transaction_without_consistency_checks(&nodes[1], &as_closing_tx[0]); } - let (_, bc_update_id) = get_latest_mon_update_id(&nodes[1], chan_id_bc); + let (_, bc_update_id) = nodes[1].chain_monitor.get_latest_mon_update_id(chan_id_bc); let mut events = nodes[1].node.get_and_clear_pending_events(); assert_eq!(events.len(), if close_during_reload { 2 } else { 1 }); expect_payment_forwarded( @@ -4084,7 +4077,7 @@ fn do_test_reload_mon_update_completion_actions(close_during_reload: bool) { // Once we run event processing the monitor should free, check that it was indeed the B<->C // channel which was updated. check_added_monitors(&nodes[1], if close_during_reload { 2 } else { 1 }); - let (_, post_ev_bc_update_id) = get_latest_mon_update_id(&nodes[1], chan_id_bc); + let (_, post_ev_bc_update_id) = nodes[1].chain_monitor.get_latest_mon_update_id(chan_id_bc); assert!(bc_update_id != post_ev_bc_update_id); // Finally, check that there's nothing left to do on B<->C reconnect and the channel operates @@ -4173,7 +4166,7 @@ fn do_test_glacial_peer_cant_hang(hold_chan_a: bool) { // ...but once we complete the A<->B channel preimage persistence, the B<->C channel // unlocks and we send both peers commitment updates. - let (ab_update_id, _) = get_latest_mon_update_id(&nodes[1], chan_id_ab); + let (ab_update_id, _) = nodes[1].chain_monitor.get_latest_mon_update_id(chan_id_ab); assert!(nodes[1] .chain_monitor .chain_monitor @@ -5130,7 +5123,7 @@ fn test_mpp_claim_to_holding_cell() { check_added_monitors(&nodes[3], 2); // Complete the B <-> D monitor update, freeing the first fulfill. - let (latest_id, _) = get_latest_mon_update_id(&nodes[3], chan_3_id); + let (latest_id, _) = nodes[3].chain_monitor.get_latest_mon_update_id(chan_3_id); nodes[3].chain_monitor.chain_monitor.channel_monitor_updated(chan_3_id, latest_id).unwrap(); let mut b_claim = get_htlc_update_msgs(&nodes[3], &node_b_id); @@ -5141,7 +5134,7 @@ fn test_mpp_claim_to_holding_cell() { // Finally, complete the C <-> D monitor update. Previously, this unlock failed to be processed // due to the existence of the blocked RAA update above. - let (latest_id, _) = get_latest_mon_update_id(&nodes[3], chan_4_id); + let (latest_id, _) = nodes[3].chain_monitor.get_latest_mon_update_id(chan_4_id); nodes[3].chain_monitor.chain_monitor.channel_monitor_updated(chan_4_id, latest_id).unwrap(); // Once we process monitor events (in this case by checking for the `PaymentClaimed` event, the // RAA monitor update blocked above will be released. diff --git a/lightning/src/util/test_utils.rs b/lightning/src/util/test_utils.rs index c9f9ba2d086..db0b0503118 100644 --- a/lightning/src/util/test_utils.rs +++ b/lightning/src/util/test_utils.rs @@ -550,6 +550,11 @@ impl<'a> TestChainMonitor<'a> { self.added_monitors.lock().unwrap().push((channel_id, monitor)); self.chain_monitor.load_existing_monitor(channel_id, new_monitor) } + + pub fn get_latest_mon_update_id(&self, channel_id: ChannelId) -> (u64, u64) { + let monitor_id_state = self.latest_monitor_update_id.lock().unwrap(); + monitor_id_state.get(&channel_id).unwrap().clone() + } } impl<'a> chain::Watch for TestChainMonitor<'a> { fn watch_channel( From 27ab4c74980e68a63fb91c0307e185303211871a Mon Sep 17 00:00:00 2001 From: Wilmer Paulino Date: Tue, 3 Feb 2026 16:09:06 -0800 Subject: [PATCH 018/135] Hold in-flight monitor updates until background event processing We previously assumed background events would eventually be processed prior to another `ChannelManager` write, so we would immediately remove all in-flight monitor updates that completed since the last `ChannelManager` serialization. This isn't always the case, so we now keep them all around until we're ready to handle them, i.e., when `process_background_events` is called. This was discovered while fuzzing `chanmon_consistency_target` on the main branch with some changes that allow it to connect blocks. It was triggered by reloading the `ChannelManager` after a monitor update completion for an outgoing HTLC, calling `ChannelManager::best_block_updated`, and reloading the `ChannelManager` once again. A test is included that provides a minimal reproduction of this case. Backport of 7e84268505d0c72d16f4499b53bc51a32c85fe06 --- lightning/src/ln/channelmanager.rs | 82 +++++++++++++++++++++--------- lightning/src/ln/reload_tests.rs | 81 +++++++++++++++++++++++++++++ 2 files changed, 140 insertions(+), 23 deletions(-) diff --git a/lightning/src/ln/channelmanager.rs b/lightning/src/ln/channelmanager.rs index 02bb67e15c4..ad622a20a5a 100644 --- a/lightning/src/ln/channelmanager.rs +++ b/lightning/src/ln/channelmanager.rs @@ -1275,7 +1275,11 @@ enum BackgroundEvent { /// Some [`ChannelMonitorUpdate`] (s) completed before we were serialized but we still have /// them marked pending, thus we need to run any [`MonitorUpdateCompletionAction`] (s) pending /// on a channel. - MonitorUpdatesComplete { counterparty_node_id: PublicKey, channel_id: ChannelId }, + MonitorUpdatesComplete { + counterparty_node_id: PublicKey, + channel_id: ChannelId, + highest_update_id_completed: u64, + }, } /// A pointer to a channel that is unblocked when an event is surfaced @@ -8042,8 +8046,21 @@ where BackgroundEvent::MonitorUpdateRegeneratedOnStartup { counterparty_node_id, funding_txo, channel_id, update } => { self.apply_post_close_monitor_update(counterparty_node_id, channel_id, funding_txo, update); }, - BackgroundEvent::MonitorUpdatesComplete { counterparty_node_id, channel_id } => { - self.channel_monitor_updated(&channel_id, None, &counterparty_node_id); + BackgroundEvent::MonitorUpdatesComplete { + counterparty_node_id, + channel_id, + highest_update_id_completed, + } => { + // Now that we can finally handle the background event, remove all in-flight + // monitor updates for this channel that we've known to complete, as they have + // 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( + &channel_id, + Some(highest_update_id_completed), + &counterparty_node_id, + ); }, } } @@ -17224,39 +17241,58 @@ where ($counterparty_node_id: expr, $chan_in_flight_upds: expr, $monitor: expr, $peer_state: expr, $logger: expr, $channel_info_log: expr ) => { { + // When all in-flight updates have completed after we were last serialized, we + // need to remove them. However, we can't guarantee that the next serialization + // will have happened after processing the + // `BackgroundEvent::MonitorUpdatesComplete`, so removing them now could lead to the + // channel never being resumed as the event would not be regenerated after another + // reload. At the same time, we don't want to resume the channel now because there + // may be post-update actions to handle. Therefore, we're forced to keep tracking + // the completed in-flight updates (but only when they have all completed) until we + // are processing the `BackgroundEvent::MonitorUpdatesComplete`. let mut max_in_flight_update_id = 0; - let starting_len = $chan_in_flight_upds.len(); - $chan_in_flight_upds.retain(|upd| upd.update_id > $monitor.get_latest_update_id()); - if $chan_in_flight_upds.len() < starting_len { + let num_updates_completed = $chan_in_flight_upds + .iter() + .filter(|update| { + max_in_flight_update_id = cmp::max(max_in_flight_update_id, update.update_id); + update.update_id <= $monitor.get_latest_update_id() + }) + .count(); + if num_updates_completed > 0 { log_debug!( $logger, "{} ChannelMonitorUpdates completed after ChannelManager was last serialized", - starting_len - $chan_in_flight_upds.len() + num_updates_completed, ); } + let all_updates_completed = num_updates_completed == $chan_in_flight_upds.len(); + let funding_txo = $monitor.get_funding_txo(); - for update in $chan_in_flight_upds.iter() { - log_debug!($logger, "Replaying ChannelMonitorUpdate {} for {}channel {}", - update.update_id, $channel_info_log, &$monitor.channel_id()); - max_in_flight_update_id = cmp::max(max_in_flight_update_id, update.update_id); - pending_background_events.push( - BackgroundEvent::MonitorUpdateRegeneratedOnStartup { - counterparty_node_id: $counterparty_node_id, - funding_txo: funding_txo, - channel_id: $monitor.channel_id(), - update: update.clone(), - }); - } - if $chan_in_flight_upds.is_empty() { - // We had some updates to apply, but it turns out they had completed before we - // were serialized, we just weren't notified of that. Thus, we may have to run - // the completion actions for any monitor updates, but otherwise are done. + if all_updates_completed { + log_debug!($logger, "All monitor updates completed since the ChannelManager was last serialized"); pending_background_events.push( BackgroundEvent::MonitorUpdatesComplete { counterparty_node_id: $counterparty_node_id, channel_id: $monitor.channel_id(), + highest_update_id_completed: max_in_flight_update_id, }); } else { + $chan_in_flight_upds.retain(|update| { + let replay = update.update_id > $monitor.get_latest_update_id(); + if replay { + log_debug!($logger, "Replaying ChannelMonitorUpdate {} for {}channel {}", + update.update_id, $channel_info_log, &$monitor.channel_id()); + pending_background_events.push( + BackgroundEvent::MonitorUpdateRegeneratedOnStartup { + counterparty_node_id: $counterparty_node_id, + funding_txo: funding_txo, + channel_id: $monitor.channel_id(), + update: update.clone(), + } + ); + } + replay + }); $peer_state.closed_channel_monitor_update_ids.entry($monitor.channel_id()) .and_modify(|v| *v = cmp::max(max_in_flight_update_id, *v)) .or_insert(max_in_flight_update_id); diff --git a/lightning/src/ln/reload_tests.rs b/lightning/src/ln/reload_tests.rs index 3e2de1da833..9e169d176e6 100644 --- a/lightning/src/ln/reload_tests.rs +++ b/lightning/src/ln/reload_tests.rs @@ -1420,3 +1420,84 @@ fn test_peer_storage() { assert!(res.is_err()); } +#[test] +fn test_hold_completed_inflight_monitor_updates_upon_manager_reload() { + // Test that if a `ChannelMonitorUpdate` completes after the `ChannelManager` is serialized, + // but before it is deserialized, we hold any completed in-flight updates until background event + // processing. Previously, we would remove completed monitor updates from + // `in_flight_monitor_updates` during deserialization, relying on + // [`ChannelManager::process_background_events`] to eventually be called before the + // `ChannelManager` is serialized again such that the channel is resumed and further updates can + // be made. + let chanmon_cfgs = create_chanmon_cfgs(2); + let node_cfgs = create_node_cfgs(2, &chanmon_cfgs); + let (persister_a, persister_b); + let (chain_monitor_a, chain_monitor_b); + + let node_chanmgrs = create_node_chanmgrs(2, &node_cfgs, &[None, None]); + let nodes_0_deserialized_a; + let nodes_0_deserialized_b; + + let mut nodes = create_network(2, &node_cfgs, &node_chanmgrs); + let chan_id = create_announced_chan_between_nodes(&nodes, 0, 1).2; + + send_payment(&nodes[0], &[&nodes[1]], 1_000_000); + + chanmon_cfgs[0].persister.set_update_ret(ChannelMonitorUpdateStatus::InProgress); + + // Send a payment that will be pending due to an async monitor update. + 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); + nodes[0].node.send_payment_with_route(route, payment_hash, onion, payment_id).unwrap(); + check_added_monitors(&nodes[0], 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()); + + // Serialize the ChannelManager while the monitor update is still in-flight. + let node_0_serialized = nodes[0].node.encode(); + + // Now complete the monitor update by calling force_channel_monitor_updated. + // This updates the monitor's state, but the ChannelManager still thinks it's pending. + let (_, latest_update_id) = nodes[0].chain_monitor.get_latest_mon_update_id(chan_id); + nodes[0].chain_monitor.chain_monitor.force_channel_monitor_updated(chan_id, latest_update_id); + let monitor_serialized_updated = get_monitor!(nodes[0], chan_id).encode(); + + // Reload the node with the updated monitor. Upon deserialization, the ChannelManager will + // detect that the monitor update completed (monitor's update_id >= the in-flight update_id) + // and queue a `BackgroundEvent::MonitorUpdatesComplete`. + nodes[0].node.peer_disconnected(nodes[1].node.get_our_node_id()); + nodes[1].node.peer_disconnected(nodes[0].node.get_our_node_id()); + reload_node!( + nodes[0], + test_default_channel_config(), + &node_0_serialized, + &[&monitor_serialized_updated[..]], + persister_a, + chain_monitor_a, + nodes_0_deserialized_a + ); + + // If we serialize again, even though we haven't processed any background events yet, we should + // still see the `BackgroundEvent::MonitorUpdatesComplete` be regenerated on startup. + let node_0_serialized = nodes[0].node.encode(); + reload_node!( + nodes[0], + test_default_channel_config(), + &node_0_serialized, + &[&monitor_serialized_updated[..]], + persister_b, + chain_monitor_b, + nodes_0_deserialized_b + ); + + // Reconnect the nodes. We should finally see the `update_add_htlc` go out, as the reconnection + // should first process `BackgroundEvent::MonitorUpdatesComplete, allowing the channel to be + // resumed. + let mut reconnect_args = ReconnectArgs::new(&nodes[0], &nodes[1]); + reconnect_args.pending_htlc_adds = (0, 1); + reconnect_nodes(reconnect_args); +} + From 8d4231ca1f5ca8daba42b6bf898e74b88b82b5db Mon Sep 17 00:00:00 2001 From: Wilmer Paulino Date: Thu, 5 Feb 2026 08:49:22 -0800 Subject: [PATCH 019/135] Rustfmt ChannelManager::process_background_events Backport of f128b8504d1724008eab10d37ad9f619657d1a24 --- lightning/src/ln/channelmanager.rs | 20 ++++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/lightning/src/ln/channelmanager.rs b/lightning/src/ln/channelmanager.rs index ad622a20a5a..816eaee8db2 100644 --- a/lightning/src/ln/channelmanager.rs +++ b/lightning/src/ln/channelmanager.rs @@ -8029,9 +8029,11 @@ where /// Free the background events, generally called from [`PersistenceNotifierGuard`] constructors. /// /// Expects the caller to have a total_consistency_lock read lock. - #[rustfmt::skip] fn process_background_events(&self) -> NotifyOption { - debug_assert_ne!(self.total_consistency_lock.held_by_thread(), LockHeldState::NotHeldByThread); + debug_assert_ne!( + self.total_consistency_lock.held_by_thread(), + LockHeldState::NotHeldByThread + ); self.background_events_processed_since_startup.store(true, Ordering::Release); @@ -8043,8 +8045,18 @@ where for event in background_events.drain(..) { match event { - BackgroundEvent::MonitorUpdateRegeneratedOnStartup { counterparty_node_id, funding_txo, channel_id, update } => { - self.apply_post_close_monitor_update(counterparty_node_id, channel_id, funding_txo, update); + BackgroundEvent::MonitorUpdateRegeneratedOnStartup { + counterparty_node_id, + funding_txo, + channel_id, + update, + } => { + self.apply_post_close_monitor_update( + counterparty_node_id, + channel_id, + funding_txo, + update, + ); }, BackgroundEvent::MonitorUpdatesComplete { counterparty_node_id, From 15f7bbe86138663f14b477b0670cbfa1feb2dc60 Mon Sep 17 00:00:00 2001 From: Matt Corallo Date: Thu, 5 Feb 2026 14:49:38 +0000 Subject: [PATCH 020/135] Switch `SplicePrototype` feature flag to the prod feature bit When we shipped 0.2 we used the feature bit 155 to signal splicing, in line with what eclair was using. However, eclair was actually using that bit to signal splicing on a previous design which is incompatible with the current spec. The result of this was that eclair nodes may attempt to splice using their protocol and we'd fail to deserialize their splice message (resulting in a reconnect, which luckily would clear their splice attempt and return the connection to normal). As we really need to get off of their feature bit and there's not much reason to keep using a non-final-spec bit, we simply redefine `SplicePrototype` to bit 63 here. Backport of 98c3cfff8f850b8b00532fc0dd715772928fcee8 --- lightning-types/src/features.rs | 28 ++++++++-------------------- 1 file changed, 8 insertions(+), 20 deletions(-) diff --git a/lightning-types/src/features.rs b/lightning-types/src/features.rs index 54f1d9e44f0..8c512401f58 100644 --- a/lightning-types/src/features.rs +++ b/lightning-types/src/features.rs @@ -166,7 +166,7 @@ mod sealed { // Byte 6 ZeroConf, // Byte 7 - Trampoline | SimpleClose | SpliceProduction, + Trampoline | SimpleClose | SpliceProduction | SplicePrototype, // Byte 8 - 16 ,,,,,,,,, // Byte 17 @@ -174,7 +174,7 @@ mod sealed { // Byte 18 , // Byte 19 - HtlcHold | SplicePrototype, + HtlcHold, ] ); define_context!( @@ -195,7 +195,7 @@ mod sealed { // Byte 6 ZeroConf | Keysend, // Byte 7 - Trampoline | SimpleClose | SpliceProduction, + Trampoline | SimpleClose | SpliceProduction | SplicePrototype, // Byte 8 - 16 ,,,,,,,,, // Byte 17 @@ -203,7 +203,7 @@ mod sealed { // Byte 18 , // Byte 19 - HtlcHold | SplicePrototype, + HtlcHold, // Byte 20 - 31 ,,,,,,,,,,,, // Byte 32 @@ -722,7 +722,7 @@ mod sealed { requires_htlc_hold ); define_feature!( - 155, // Splice prototype feature bit as listed in https://github.com/lightning/bolts/issues/605#issuecomment-877237519. + 63, // Actually the SpliceProduction feature SplicePrototype, [InitContext, NodeContext], "Feature flags for channel splicing.", @@ -1441,8 +1441,8 @@ mod tests { // - onion_messages // - option_channel_type | option_scid_alias // - option_zeroconf - // - option_simple_close | option_splice - assert_eq!(node_features.flags.len(), 20); + // - option_simple_close + assert_eq!(node_features.flags.len(), 8); assert_eq!(node_features.flags[0], 0b00000001); assert_eq!(node_features.flags[1], 0b01010001); assert_eq!(node_features.flags[2], 0b10001010); @@ -1450,19 +1450,7 @@ mod tests { assert_eq!(node_features.flags[4], 0b10001000); assert_eq!(node_features.flags[5], 0b10100000); assert_eq!(node_features.flags[6], 0b00001000); - assert_eq!(node_features.flags[7], 0b00100000); - assert_eq!(node_features.flags[8], 0b00000000); - assert_eq!(node_features.flags[9], 0b00000000); - assert_eq!(node_features.flags[10], 0b00000000); - assert_eq!(node_features.flags[11], 0b00000000); - assert_eq!(node_features.flags[12], 0b00000000); - assert_eq!(node_features.flags[13], 0b00000000); - assert_eq!(node_features.flags[14], 0b00000000); - assert_eq!(node_features.flags[15], 0b00000000); - assert_eq!(node_features.flags[16], 0b00000000); - assert_eq!(node_features.flags[17], 0b00000000); - assert_eq!(node_features.flags[18], 0b00000000); - assert_eq!(node_features.flags[19], 0b00001000); + assert_eq!(node_features.flags[7], 0b10100000); } // Check that cleared flags are kept blank when converting back: From b86fa8e5c79e00f5182fac453fd3250cb13d2ea4 Mon Sep 17 00:00:00 2001 From: Jeffrey Czyz Date: Wed, 4 Feb 2026 09:52:19 -0600 Subject: [PATCH 021/135] Use SignedAmount::unsigned_abs to avoid overflow In debug mode, using SignedAmount::abs can lead to an integer overflow when used with SignedAmount::MIN. Use SignedAmount::unsigned_abs to avoid this. Backport of 2d948fdd33bd3f509fae90f588b27c040a15d7aa Conflicts resolved in: * lightning/src/ln/channel.rs --- lightning/src/ln/channel.rs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/lightning/src/ln/channel.rs b/lightning/src/ln/channel.rs index 289a241bc43..02526d5a98b 100644 --- a/lightning/src/ln/channel.rs +++ b/lightning/src/ln/channel.rs @@ -2521,8 +2521,8 @@ impl FundingScope { where SP::Target: SignerProvider, { - debug_assert!(our_funding_contribution.abs() <= SignedAmount::MAX_MONEY); - debug_assert!(their_funding_contribution.abs() <= SignedAmount::MAX_MONEY); + debug_assert!(our_funding_contribution.unsigned_abs() <= Amount::MAX_MONEY); + debug_assert!(their_funding_contribution.unsigned_abs() <= Amount::MAX_MONEY); let post_channel_value = prev_funding.compute_post_splice_value( our_funding_contribution.to_sat(), @@ -12137,7 +12137,7 @@ where fn validate_splice_contributions( &self, our_funding_contribution: SignedAmount, their_funding_contribution: SignedAmount, ) -> Result<(), String> { - if our_funding_contribution.abs() > SignedAmount::MAX_MONEY { + 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(), @@ -12145,7 +12145,7 @@ where )); } - if their_funding_contribution.abs() > SignedAmount::MAX_MONEY { + 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(), From df44c3bec8d772f917c2628f742467b2c4a2597a Mon Sep 17 00:00:00 2001 From: Matt Corallo Date: Thu, 5 Feb 2026 20:41:36 +0000 Subject: [PATCH 022/135] Bump crate versions/CHANGELOG for 0.2.2 --- CHANGELOG.md | 17 +++++++++++++++++ lightning-types/Cargo.toml | 2 +- lightning/Cargo.toml | 2 +- 3 files changed, 19 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f2794da06e7..33eb3a787e4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,20 @@ +# 0.2.2 - Feb 6, 2025 - "An Async Splicing Production" + +## API Updates + * The `SplicePrototype` feature flag has been updated to refer to feature bit + 63 - the same as `SpliceProduction`. This resolves a compatibility issue with + eclair nodes due to the use of the same splicing feature flag (155) they were + using for a pre-standardization version of splicing (#4387). + +## Bug Fixes + * Async `ChannelMonitorUpdate` persistence operations which complete, but are + not marked as complete in a persisted `ChannelManager` prior to restart, + followed immediately by a block connection and then another restart could + result in some channel operations hanging leading for force-closures (#4377). + * A debug assertion failure reachable when receiving an invalid splicing + message from a peer was fixed (#4383). + + # 0.2.1 - Jan 29, 2025 - "Electrum Confirmations Logged" ## API Updates diff --git a/lightning-types/Cargo.toml b/lightning-types/Cargo.toml index d492698eb4c..32552def61d 100644 --- a/lightning-types/Cargo.toml +++ b/lightning-types/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "lightning-types" -version = "0.3.0" +version = "0.3.1" authors = ["Matt Corallo"] license = "MIT OR Apache-2.0" repository = "https://github.com/lightningdevkit/rust-lightning/" diff --git a/lightning/Cargo.toml b/lightning/Cargo.toml index 55e4b40144f..2e0ddd389ed 100644 --- a/lightning/Cargo.toml +++ b/lightning/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "lightning" -version = "0.2.1" +version = "0.2.2" authors = ["Matt Corallo"] license = "MIT OR Apache-2.0" repository = "https://github.com/lightningdevkit/rust-lightning/" From cb5cdd8a69eb6a61e9305b3bf3b14a8abbc55763 Mon Sep 17 00:00:00 2001 From: Vincenzo Palazzo Date: Tue, 20 Jan 2026 14:22:48 +0100 Subject: [PATCH 023/135] 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 Backport of a06c44698c6861b9a770711f33f3d441f4c64a3f Conflicts resolved in: * lightning/src/offers/offer.rs Compared to the upstream commit, this instead allows downstream code to pass a 0 amount but converts it to no-amount to ensure upgraded readers of the built offer will accept it. This avoids changing the API in a backport. --- lightning/src/offers/offer.rs | 65 ++++++++++++++++++++++++++++++++++- 1 file changed, 64 insertions(+), 1 deletion(-) diff --git a/lightning/src/offers/offer.rs b/lightning/src/offers/offer.rs index 7eb719c104a..5e66b1c9924 100644 --- a/lightning/src/offers/offer.rs +++ b/lightning/src/offers/offer.rs @@ -402,6 +402,9 @@ 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 == 0 { + $self.offer.amount = None; + } if amount_msats > MAX_VALUE_MSAT { return Err(Bolt12SemanticError::InvalidAmount); } @@ -1306,11 +1309,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 +1706,12 @@ mod tests { Ok(_) => panic!("expected error"), Err(e) => assert_eq!(e, Bolt12SemanticError::InvalidAmount), } + + // An amount of 0 is rejected per BOLT 12, so we map it to `None` instead. + match OfferBuilder::new(pubkey(42)).amount_msats(0).build() { + Ok(offer) => assert_eq!(offer.amount(), None), + Err(_) => panic!("expected offer"), + } } #[test] @@ -1974,6 +1984,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 fa77f1c38f21868a5256a3d52bf8fbfd483d5314 Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Thu, 27 Nov 2025 09:55:51 +0100 Subject: [PATCH 024/135] Only log if we actually persisted `LiquidityManager` data Previously, we logged "Persisting LiquidityManager..." on each background processor wakeup, which can be very spammy, even on TRACE level. Here, we opt to only log if something actually needed to be repersisted and we did so (in case of failure we're logging that anyways, too). Backport of 369ea98054b37650538b1c68ac28821cbfc9a75f --- lightning-background-processor/src/lib.rs | 18 ++++++++++++----- lightning-liquidity/src/events/event_queue.rs | 6 +++--- lightning-liquidity/src/lsps2/service.rs | 9 ++++++--- lightning-liquidity/src/lsps5/service.rs | 10 +++++++--- lightning-liquidity/src/manager.rs | 20 ++++++++++++------- 5 files changed, 42 insertions(+), 21 deletions(-) diff --git a/lightning-background-processor/src/lib.rs b/lightning-background-processor/src/lib.rs index 31e519b9f57..cdf1b2e5aa3 100644 --- a/lightning-background-processor/src/lib.rs +++ b/lightning-background-processor/src/lib.rs @@ -1230,12 +1230,20 @@ where } if let Some(liquidity_manager) = liquidity_manager.as_ref() { - log_trace!(logger, "Persisting LiquidityManager..."); let fut = async { - liquidity_manager.get_lm().persist().await.map_err(|e| { - log_error!(logger, "Persisting LiquidityManager failed: {}", e); - e - }) + liquidity_manager + .get_lm() + .persist() + .await + .map(|did_persist| { + if did_persist { + log_trace!(logger, "Persisted LiquidityManager."); + } + }) + .map_err(|e| { + log_error!(logger, "Persisting LiquidityManager failed: {}", e); + e + }) }; futures.set_e(Box::pin(fut)); } diff --git a/lightning-liquidity/src/events/event_queue.rs b/lightning-liquidity/src/events/event_queue.rs index cd1162cee31..0d6e3a0ec54 100644 --- a/lightning-liquidity/src/events/event_queue.rs +++ b/lightning-liquidity/src/events/event_queue.rs @@ -129,12 +129,12 @@ where EventQueueNotifierGuard(self) } - pub async fn persist(&self) -> Result<(), lightning::io::Error> { + pub async fn persist(&self) -> Result { let fut = { let mut state_lock = self.state.lock().unwrap(); if !state_lock.needs_persist { - return Ok(()); + return Ok(false); } state_lock.needs_persist = false; @@ -153,7 +153,7 @@ where e })?; - Ok(()) + Ok(true) } } diff --git a/lightning-liquidity/src/lsps2/service.rs b/lightning-liquidity/src/lsps2/service.rs index dda9922686d..82037653780 100644 --- a/lightning-liquidity/src/lsps2/service.rs +++ b/lightning-liquidity/src/lsps2/service.rs @@ -1782,15 +1782,16 @@ where }) } - pub(crate) async fn persist(&self) -> Result<(), lightning::io::Error> { + 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(()); + return Ok(did_persist); } loop { @@ -1816,6 +1817,7 @@ where 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 { @@ -1850,6 +1852,7 @@ where } if let Some(future) = future_opt { future.await?; + did_persist = true; } else { self.persist_peer_state(counterparty_node_id).await?; } @@ -1864,7 +1867,7 @@ where break; } - Ok(()) + Ok(did_persist) } pub(crate) fn peer_disconnected(&self, counterparty_node_id: PublicKey) { diff --git a/lightning-liquidity/src/lsps5/service.rs b/lightning-liquidity/src/lsps5/service.rs index 8b1f0ec70cb..adf3da2baef 100644 --- a/lightning-liquidity/src/lsps5/service.rs +++ b/lightning-liquidity/src/lsps5/service.rs @@ -244,15 +244,17 @@ where }) } - pub(crate) async fn persist(&self) -> Result<(), lightning::io::Error> { + 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(()); + return Ok(did_persist); } loop { @@ -277,6 +279,7 @@ where 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 { @@ -311,6 +314,7 @@ where } if let Some(future) = future_opt { future.await?; + did_persist = true; } else { self.persist_peer_state(client_id).await?; } @@ -325,7 +329,7 @@ where break; } - Ok(()) + Ok(did_persist) } fn check_prune_stale_webhooks<'a>( diff --git a/lightning-liquidity/src/manager.rs b/lightning-liquidity/src/manager.rs index 5d95d32d540..de84ee20897 100644 --- a/lightning-liquidity/src/manager.rs +++ b/lightning-liquidity/src/manager.rs @@ -670,23 +670,27 @@ where self.pending_events.get_and_clear_pending_events() } - /// Persists the state of the service handlers towards the given [`KVStore`] implementation. + /// Persists the state of the service handlers towards the given [`KVStore`] implementation if + /// needed. + /// + /// Returns `true` if it persisted sevice 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. - pub async fn persist(&self) -> Result<(), lightning::io::Error> { + pub async fn persist(&self) -> Result { // TODO: We should eventually persist in parallel. - self.pending_events.persist().await?; + let mut did_persist = false; + did_persist |= self.pending_events.persist().await?; if let Some(lsps2_service_handler) = self.lsps2_service_handler.as_ref() { - lsps2_service_handler.persist().await?; + did_persist |= lsps2_service_handler.persist().await?; } if let Some(lsps5_service_handler) = self.lsps5_service_handler.as_ref() { - lsps5_service_handler.persist().await?; + did_persist |= lsps5_service_handler.persist().await?; } - Ok(()) + Ok(did_persist) } fn handle_lsps_message( @@ -1285,8 +1289,10 @@ where /// Persists the state of the service handlers towards the given [`KVStoreSync`] implementation. /// + /// Returns `true` if it persisted sevice handler data. + /// /// Wraps [`LiquidityManager::persist`]. - pub fn persist(&self) -> Result<(), lightning::io::Error> { + pub fn persist(&self) -> Result { let mut waker = dummy_waker(); let mut ctx = task::Context::from_waker(&mut waker); match Box::pin(self.inner.persist()).as_mut().poll(&mut ctx) { From acca80c441411f2fe4f47f17574663221c5f1bb8 Mon Sep 17 00:00:00 2001 From: Philip Kannegaard Hayes Date: Thu, 26 Mar 2026 18:31:36 -0700 Subject: [PATCH 025/135] types: fix zero conf feature missing `clear_zero_conf` Backport of 28f10a547639f0a0a22022b9ab24378aad49596a --- 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 8c512401f58..de8c97e6da6 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 5758dc66187172b052017348b15221e4bcbd136d Mon Sep 17 00:00:00 2001 From: Matt Corallo Date: Sun, 29 Mar 2026 16:23:35 +0000 Subject: [PATCH 026/135] 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 Backport of b0c312dbd25816af70dc16685eec5584bd6a5822 Conflicts resolved in: * lightning/src/ln/channelmanager.rs --- 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 816eaee8db2..a3708b29aa4 100644 --- a/lightning/src/ln/channelmanager.rs +++ b/lightning/src/ln/channelmanager.rs @@ -1280,6 +1280,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 @@ -8074,6 +8079,12 @@ where &counterparty_node_id, ); }, + BackgroundEvent::AttemptUnblockMonitorUpdates { + counterparty_node_id, + channel_id, + } => { + self.handle_monitor_update_release(counterparty_node_id, channel_id, None); + }, } } NotifyOption::DoPersist @@ -9359,6 +9370,7 @@ This indicates a bug inside LDK. Please report this error at https://github.com/ channel_id, .. } => *channel_id == prev_channel_id, + BackgroundEvent::AttemptUnblockMonitorUpdates { .. } => false, } }); assert!( @@ -17366,6 +17378,14 @@ where 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 5d4f6d9de7b60046a386460dd24e1282f1723354 Mon Sep 17 00:00:00 2001 From: Joost Jager Date: Wed, 6 May 2026 19:54:19 +0000 Subject: [PATCH 027/135] 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. Backport of 01d55dc1651ceffa560cd79c8993dbc7755383e8 Conflicts due to different channel sorting and holding cell free timing resolved in: * lightning/src/ln/reload_tests.rs --- lightning/src/ln/reload_tests.rs | 357 +++++++++++++++++++++++++++++++ 1 file changed, 357 insertions(+) diff --git a/lightning/src/ln/reload_tests.rs b/lightning/src/ln/reload_tests.rs index 9e169d176e6..9669ff5f05f 100644 --- a/lightning/src/ln/reload_tests.rs +++ b/lightning/src/ln/reload_tests.rs @@ -919,6 +919,363 @@ 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_b = create_announced_chan_between_nodes_with_value(&nodes, 0, 1, 100_000, 0); + let chan_a = 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 = 20_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, _) = nodes[1].chain_monitor.latest_monitor_update_id.lock().unwrap().get(&chan_id_a).unwrap().clone(); + 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. + // Note that unlike the test in 0.3, we only generate the last monitor update for node B after + // get_and_clear_pending_msg_events as we only free the holding cell then. + check_added_monitors(&nodes[0], 0); + check_added_monitors(&nodes[1], 2); + + // 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(); + check_added_monitors(&nodes[1], 1); + 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 (unlike this test in 0.3, after we free the holding cell in + // get_and_clear_pending_msg_events below). + check_added_monitors(&nodes[1], 1); + + // 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(); + check_added_monitors(&nodes[1], 1); + 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 b65ef096becfb94d82769fe5cb55d8dc311694cd Mon Sep 17 00:00:00 2001 From: Matt Corallo Date: Thu, 2 Apr 2026 16:38:15 +0000 Subject: [PATCH 028/135] 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. Backport of f14b4b2fd5889da3265be19db0891adcbc67068b Trivial silent conflict resolved in: * lightning/src/ln/functional_tests.rs --- 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 a3708b29aa4..4145e500d43 100644 --- a/lightning/src/ln/channelmanager.rs +++ b/lightning/src/ln/channelmanager.rs @@ -9088,12 +9088,12 @@ where { 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. @@ -9103,6 +9103,9 @@ where } *iter != blocker || !first_blocker }); + if entry.get().is_empty() { + entry.remove(); + } debug_assert!(found_blocker); } } else { @@ -13599,10 +13602,12 @@ where 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 36fb17ff076..3ad06e72b7d 100644 --- a/lightning/src/ln/functional_tests.rs +++ b/lightning/src/ln/functional_tests.rs @@ -9999,3 +9999,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_default_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 822ae83bbf14212619a80f8eb11214e9c800f8e4 Mon Sep 17 00:00:00 2001 From: Matt Corallo Date: Tue, 7 Apr 2026 01:19:01 +0000 Subject: [PATCH 029/135] 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. Backport of 417b06585cf78728e3d0659e078b24c096d78bca Trivial silent conflict resolved in: * lightning/src/offers/flow.rs --- lightning/src/offers/flow.rs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/lightning/src/offers/flow.rs b/lightning/src/offers/flow.rs index 15e744e1a7a..d8fca367dff 100644 --- a/lightning/src/offers/flow.rs +++ b/lightning/src/offers/flow.rs @@ -210,10 +210,12 @@ where /// /// 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 @@ -235,7 +237,7 @@ where #[cfg(feature = "dnssec")] { let updated_time = timestamp.load(Ordering::Acquire) as u32; - self.hrn_resolver.new_best_block(_height, updated_time); + self.hrn_resolver.new_best_block(height, updated_time); } } } From dbc581419ceabc3e336ec48ca1bea038b634d5c2 Mon Sep 17 00:00:00 2001 From: Swagmuffin Date: Mon, 6 Apr 2026 19:14:45 -0700 Subject: [PATCH 030/135] Bypass channel monitor sync requests when no partition key given Backport of 6bf2352dc9eb7aaeafacd418a95e9d28d139f2d0 Conflicts resolved in: * lightning/src/chain/chainmonitor.rs --- 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 d8d6c90921f..b060c9b0601 100644 --- a/lightning/src/chain/chainmonitor.rs +++ b/lightning/src/chain/chainmonitor.rs @@ -569,7 +569,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 { @@ -579,7 +579,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 for channel {}", From 296de0ef2c482cf10c1140dce2e177b538a37e05 Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Mon, 13 Apr 2026 10:49:06 +0200 Subject: [PATCH 031/135] 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 Backport of fd8846b5c8016f7b34166a69e8d3bd9617622611 Conflicts resolved in: * lightning/src/ln/channelmanager.rs * lightning/src/ln/payment_tests.rs --- lightning/src/ln/channelmanager.rs | 37 ++++++++++------------ lightning/src/ln/payment_tests.rs | 51 +++++++++++++++++++++++++----- 2 files changed, 60 insertions(+), 28 deletions(-) diff --git a/lightning/src/ln/channelmanager.rs b/lightning/src/ln/channelmanager.rs index 4145e500d43..bd88fa88bc3 100644 --- a/lightning/src/ln/channelmanager.rs +++ b/lightning/src/ln/channelmanager.rs @@ -8368,26 +8368,23 @@ where 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 htlc_total_msat = - payment.htlcs.iter().map(|h| h.sender_intended_value).sum(); - if payment.htlcs[0].total_msat <= htlc_total_msat { - 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 htlc_total_msat = + payment.htlcs.iter().map(|h| h.sender_intended_value).sum(); + if payment.htlcs[0].total_msat <= htlc_total_msat { + 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 bab2a16bef9..4da07ff0407 100644 --- a/lightning/src/ln/payment_tests.rs +++ b/lightning/src/ln/payment_tests.rs @@ -332,7 +332,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]); @@ -348,8 +348,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; @@ -361,8 +365,23 @@ fn do_mpp_receive_timeout(send_partial_mpp: bool) { // Initiate the MPP payment. let onion = RecipientOnionFields::secret_only(payment_secret); - nodes[0].node.send_payment_with_route(route, hash, onion, PaymentId(hash.0)).unwrap(); - check_added_monitors!(nodes[0], 2); // one monitor per path + 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); @@ -408,7 +427,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 { @@ -422,8 +451,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 afb852597c43cab9151d9097e72ad787f2034e2a Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Tue, 5 May 2026 19:45:38 +0200 Subject: [PATCH 032/135] 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 Backport of 8b383bb8d7586f151ba9cde8bfbe5f991473199c --- 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 1162b9c00c9..70e4f79a595 100644 --- a/lightning-transaction-sync/src/electrum.rs +++ b/lightning-transaction-sync/src/electrum.rs @@ -335,11 +335,11 @@ where 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 5b76b6133a55932ed3a4408f79e526d1f29500f3 Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Tue, 5 May 2026 19:50:17 +0200 Subject: [PATCH 033/135] 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 Backport of fb4103d7788414b2b462911dfb988c70380b5f1d Trivial conflicts resolved in: * lightning-dns-resolver/src/lib.rs --- lightning-dns-resolver/src/lib.rs | 92 ++++++++++++++++++++++++++++++- 1 file changed, 91 insertions(+), 1 deletion(-) diff --git a/lightning-dns-resolver/src/lib.rs b/lightning-dns-resolver/src/lib.rs index f5b1d53fc8a..fbafeb76860 100644 --- a/lightning-dns-resolver/src/lib.rs +++ b/lightning-dns-resolver/src/lib.rs @@ -135,8 +135,8 @@ where 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 } @@ -518,4 +518,94 @@ mod test { ) .await; } + + #[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, + &*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 90ad6bf901444c740cce2fe43f0fd356fea0a895 Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Tue, 5 May 2026 20:01:08 +0200 Subject: [PATCH 034/135] 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 Backport of 33987e869f0511aa515048737fdeaa4c0a612b9b Trivial conflicts resolved in: * lightning/src/util/anchor_channel_reserves.rs --- lightning/src/util/anchor_channel_reserves.rs | 57 ++++++++++++++++++- 1 file changed, 55 insertions(+), 2 deletions(-) diff --git a/lightning/src/util/anchor_channel_reserves.rs b/lightning/src/util/anchor_channel_reserves.rs index e50e103211f..cef76d53bb2 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. /// @@ -311,7 +318,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); @@ -320,7 +327,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); } } @@ -330,6 +337,7 @@ where #[cfg(test)] mod test { use super::*; + use crate::ln::functional_test_utils::*; use bitcoin::{OutPoint, ScriptBuf, TxOut, Txid}; use std::str::FromStr; @@ -439,4 +447,49 @@ 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; + cfg.manually_accept_inbound_channels = 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 bde197e0614bfc592f19b7f46040a76240c90e8c Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Tue, 5 May 2026 20:10:01 +0200 Subject: [PATCH 035/135] 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 Backport of 1a01b5ae4fb74bfff763b968719e362e546bd594 --- 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 d9f4e77f056480e6a119af2d84be6e9d528364d5 Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Tue, 5 May 2026 20:58:22 +0200 Subject: [PATCH 036/135] 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 Backport of c005b11dc36d3d48f1916d24a21da742d37709d6 --- 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 77f486a6a06..8d4b75bb627 100644 --- a/lightning/src/offers/static_invoice.rs +++ b/lightning/src/offers/static_invoice.rs @@ -400,7 +400,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 @@ -993,6 +993,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 59e61ee07aae222306e1089681f5378bef133868 Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Tue, 5 May 2026 21:15:50 +0200 Subject: [PATCH 037/135] 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 Backport of 5455058ef2ec7994e4f19311477ecc662354dc52 --- 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 0fada15c0162374d45b21647e8c4e116236fd110 Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Tue, 5 May 2026 21:22:51 +0200 Subject: [PATCH 038/135] 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 Backport of b64efcda8835c2b1aed3e8f20d186657b00b5ed9 --- 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 a191260bc01..e96110cc164 100644 --- a/lightning-transaction-sync/src/esplora.rs +++ b/lightning-transaction-sync/src/esplora.rs @@ -367,8 +367,13 @@ where 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 9e79c84359e890a44c9d9ad87a34f291eac66b2e Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Tue, 5 May 2026 22:12:28 +0200 Subject: [PATCH 039/135] 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 Backport of b3544defd8e614c1ce88600064d3b12fe7e93679 Silent conflicts resolved in: * lightning-liquidity/tests/lsps5_integration_tests.rs --- lightning-liquidity/src/lsps5/service.rs | 135 +++++++------ .../tests/lsps5_integration_tests.rs | 180 ++++++++++++++++++ 2 files changed, 254 insertions(+), 61 deletions(-) diff --git a/lightning-liquidity/src/lsps5/service.rs b/lightning-liquidity/src/lsps5/service.rs index adf3da2baef..e19477c187c 100644 --- a/lightning-liquidity/src/lsps5/service.rs +++ b/lightning-liquidity/src/lsps5/service.rs @@ -249,84 +249,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 80707a60774..f144f8b1231 100644 --- a/lightning-liquidity/tests/lsps5_integration_tests.rs +++ b/lightning-liquidity/tests/lsps5_integration_tests.rs @@ -1648,3 +1648,183 @@ 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 { + #[cfg(lsps1_service)] + 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 chain_params = ChainParameters { + network: Network::Testnet, + best_block: BestBlock::from_network(Network::Testnet), + }; + + let service_lm = LiquidityManagerSync::new_with_custom_time_provider( + nodes[0].keys_manager, + nodes[0].keys_manager, + nodes[0].node, + None::>, + Some(chain_params), + 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, + None::>, + Some(chain_params), + 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 a684d2b0253c1e711e749cca64ed5f438da33f34 Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Tue, 5 May 2026 22:17:31 +0200 Subject: [PATCH 040/135] 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 Backport of 6394d18bf088bc27c05f23696ec22f5ef44f5b3c Silent conflicts due to API changes resolved in: * lightning/src/util/sweep.rs --- lightning/src/util/sweep.rs | 154 ++++++++++++++++++++++++++++++++++-- 1 file changed, 149 insertions(+), 5 deletions(-) diff --git a/lightning/src/util/sweep.rs b/lightning/src/util/sweep.rs index b31dab1ccf6..a10ecdc01e2 100644 --- a/lightning/src/util/sweep.rs +++ b/lightning/src/util/sweep.rs @@ -473,12 +473,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 @@ -1111,3 +1117,141 @@ 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 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]) {} + } + + 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, + ) -> AsyncResult<'a, ScriptBuf, ()> { + Box::pin(core_future::ready(Ok(ScriptBuf::new()))) + } + } + + struct PendingKVStore; + impl KVStore for PendingKVStore { + fn read( + &self, _: &str, _: &str, _: &str, + ) -> AsyncResult<'static, Vec, io::Error> { + Box::pin(core_future::ready(Err(io::Error::new(io::ErrorKind::NotFound, "")))) + } + fn write( + &self, _: &str, _: &str, _: &str, _: Vec, + ) -> AsyncResult<'static, (), io::Error> { + Box::pin(core_future::pending()) + } + fn remove( + &self, _: &str, _: &str, _: &str, _: bool, + ) -> AsyncResult<'static, (), io::Error> { + Box::pin(core_future::ready(Ok(()))) + } + fn list( + &self, _: &str, _: &str, + ) -> AsyncResult<'static, Vec, io::Error> { + Box::pin(core_future::ready(Ok(Vec::new()))) + } + } + + #[test] + fn pending_sweep_flag_resets_after_future_drop() { + let best_block = BestBlock::new(BlockHash::all_zeros(), 1_000); + + let sweeper: OutputSweeper<_, _, _, _, _, _, _> = OutputSweeper::new( + best_block, + &DummyBroadcaster, + &DummyFeeEstimator, + None::<&DummyFilter>, + &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, + 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 3504877b5242827dd6f97c62fabeb4171a331758 Mon Sep 17 00:00:00 2001 From: Matt Corallo Date: Thu, 7 May 2026 18:33:12 +0000 Subject: [PATCH 041/135] 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 Backport of 65e8cc8d5bb85af67efc29c006c613804ba1f44f --- .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 1f9d08fdb6da233bba13a34a3d8d07d7ee09352f Mon Sep 17 00:00:00 2001 From: benthecarman Date: Wed, 10 Jun 2026 20:07:01 +0000 Subject: [PATCH 042/135] 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. Backport of 9246d868cf64dd3ec960956597e64448a59537c6 Conflicts resolved due to moved/split file in: * lightning-persister/src/fs_store.rs --- lightning-persister/src/fs_store.rs | 60 +++++++++++++++++++++++------ 1 file changed, 48 insertions(+), 12 deletions(-) diff --git a/lightning-persister/src/fs_store.rs b/lightning-persister/src/fs_store.rs index 9b15398d4d1..05530b8fcf0 100644 --- a/lightning-persister/src/fs_store.rs +++ b/lightning-persister/src/fs_store.rs @@ -554,20 +554,25 @@ impl KVStore for FilesystemStore { } } +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, + } +} + 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 metadata = dir_entry.metadata()?; @@ -654,6 +659,9 @@ impl MigratableKVStore for FilesystemStore { '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(); @@ -667,6 +675,9 @@ impl MigratableKVStore for FilesystemStore { '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 = @@ -681,6 +692,9 @@ impl MigratableKVStore for FilesystemStore { 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 = @@ -806,6 +820,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 922f11aa8c76e94640a882d8c3b3c97785271349 Mon Sep 17 00:00:00 2001 From: Matt Corallo Date: Fri, 29 May 2026 13:54:34 +0000 Subject: [PATCH 043/135] 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. Backport of 8c08a3065a1ff20f9b3b6f06e4c928e235e01f74 Conflicts resolved in: * lightning/src/ln/offers_tests.rs * lightning/src/onion_message/messenger.rs --- lightning/src/ln/offers_tests.rs | 154 +---------------------- lightning/src/onion_message/messenger.rs | 67 +++++----- 2 files changed, 32 insertions(+), 189 deletions(-) diff --git a/lightning/src/ln/offers_tests.rs b/lightning/src/ln/offers_tests.rs index b7d64df4063..e8832316b9a 100644 --- a/lightning/src/ln/offers_tests.rs +++ b/lightning/src/ln/offers_tests.rs @@ -53,7 +53,7 @@ use crate::events::{ClosureReason, Event, HTLCHandlingFailureType, PaidBolt12Inv use crate::ln::channelmanager::{Bolt12PaymentError, PaymentId, RecentPaymentDetails, RecipientOnionFields, Retry, self}; 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; @@ -115,38 +115,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()) @@ -315,126 +283,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 9a2c06bb72f..251c2ae84be 100644 --- a/lightning/src/onion_message/messenger.rs +++ b/lightning/src/onion_message/messenger.rs @@ -584,40 +584,10 @@ where // 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)); - 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]| { let dummy_hops_count = if compact_paths { 0 @@ -637,12 +607,37 @@ where ) }; - // 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 bffe7e5a69b63bc97b35f92bb911e17cb1bc2c59 Mon Sep 17 00:00:00 2001 From: Valentine Wallace Date: Mon, 1 Jun 2026 14:38:58 -0400 Subject: [PATCH 044/135] 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. Backport of 3e9e6e9324e8e8916d7f921485982789a860f61a Silent conflicts resolved in: * lightning/src/ln/payment_tests.rs --- 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 d97ae6097b6..c0f252dbff1 100644 --- a/lightning/src/events/mod.rs +++ b/lightning/src/events/mod.rs @@ -1049,7 +1049,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 3ad06e72b7d..54019149409 100644 --- a/lightning/src/ln/functional_tests.rs +++ b/lightning/src/ln/functional_tests.rs @@ -8405,7 +8405,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 60c33b09bea..670d3a18ba9 100644 --- a/lightning/src/ln/outbound_payment.rs +++ b/lightning/src/ln/outbound_payment.rs @@ -156,6 +156,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, }, } @@ -244,6 +247,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, } } @@ -300,6 +304,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, .. } | @@ -310,6 +315,7 @@ impl PendingOutboundPayment { payment_hash: *payment_hash, reason: Some(reason), total_msat, + pending_fee_msat, }; }, _ => {} @@ -2753,6 +2759,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 4da07ff0407..4c6fe963a6f 100644 --- a/lightning/src/ln/payment_tests.rs +++ b/lightning/src/ln/payment_tests.rs @@ -2026,6 +2026,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 = 5_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); + 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 912ec4ff35f4f30301cc457cd15bda5973a94598 Mon Sep 17 00:00:00 2001 From: Jeffrey Czyz Date: Tue, 9 Jun 2026 11:23:33 -0500 Subject: [PATCH 045/135] 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 Backport of 762012430a61f855e2abae7324e959b6d87f1bb2 --- 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 d5d3c4d75a8..9d5d09d2a24 100644 --- a/lightning/src/offers/invoice_request.rs +++ b/lightning/src/offers/invoice_request.rs @@ -2127,6 +2127,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 5e66b1c9924..e9c3deb7d9a 100644 --- a/lightning/src/offers/offer.rs +++ b/lightning/src/offers/offer.rs @@ -978,7 +978,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 2ca976c9ade6986f4529764ac05f4067a9b07547 Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Wed, 10 Jun 2026 12:55:05 +0200 Subject: [PATCH 046/135] 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 Backport of ccf45e4fa4e466811f96f11b711b4aa0c1b6cfe0 Trivial conflicts resolved in: * lightning/src/events/bump_transaction/mod.rs --- 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 11f008612a2..e8c1350e862 100644 --- a/lightning/src/events/bump_transaction/mod.rs +++ b/lightning/src/events/bump_transaction/mod.rs @@ -74,6 +74,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() @@ -1383,4 +1384,27 @@ mod tests { pending_htlcs: Vec::new(), }); } + + #[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 460d47c1daddb8ff0a766675f2dbb93c20d8fe69 Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Wed, 10 Jun 2026 13:13:36 +0200 Subject: [PATCH 047/135] 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 Backport of 5e7b7d3d2119f56503df091a23b78a4c4f85e99e Trivial conflicts resolved in: * lightning/src/util/anchor_channel_reserves.rs --- 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 cef76d53bb2..29e24c9f727 100644 --- a/lightning/src/util/anchor_channel_reserves.rs +++ b/lightning/src/util/anchor_channel_reserves.rs @@ -25,7 +25,7 @@ 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::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 { @@ -384,6 +384,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 e3229b1a2ae6e16332b12fad28e6d977ab829372 Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Wed, 10 Jun 2026 16:25:18 +0200 Subject: [PATCH 048/135] 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 Backport of 6b75e5a4e86c3c3a375c933b723508162d5119aa Conflicts resolved in: * lightning-liquidity/tests/lsps2_integration_tests.rs --- lightning-liquidity/src/lsps2/service.rs | 8 +- .../tests/lsps2_integration_tests.rs | 122 +++++++++++++++++- 2 files changed, 124 insertions(+), 6 deletions(-) diff --git a/lightning-liquidity/src/lsps2/service.rs b/lightning-liquidity/src/lsps2/service.rs index 82037653780..0cc0a4afd2d 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; 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 82f93b5990c..9c42ee6908c 100644 --- a/lightning-liquidity/tests/lsps2_integration_tests.rs +++ b/lightning-liquidity/tests/lsps2_integration_tests.rs @@ -8,7 +8,7 @@ use common::{ }; use lightning::check_added_monitors; -use lightning::events::{ClosureReason, Event}; +use lightning::events::{ClosureReason, Event, HTLCHandlingFailureType}; use lightning::get_event_msg; use lightning::ln::channelmanager::PaymentId; use lightning::ln::channelmanager::Retry; @@ -466,6 +466,126 @@ 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.accept_intercept_htlcs = true; + + 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().to_byte_array()), + None, + Default::default(), + Retry::Attempts(0), + ) + .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 c7a28dabe5fdb1efdafe4f0b52edcbf7c412a966 Mon Sep 17 00:00:00 2001 From: Leo Nash Date: Fri, 12 Jun 2026 07:46:54 +0000 Subject: [PATCH 049/135] Remove debug assert reachable if splicing a 0FC channel This was a drive-by fix in a4bf94a4e96aa208d439670488c4fc5e7d95310e --- lightning/src/sign/tx_builder.rs | 1 - 1 file changed, 1 deletion(-) diff --git a/lightning/src/sign/tx_builder.rs b/lightning/src/sign/tx_builder.rs index 74941ec8a87..e6d598f1aab 100644 --- a/lightning/src/sign/tx_builder.rs +++ b/lightning/src/sign/tx_builder.rs @@ -194,7 +194,6 @@ impl TxBuilder for SpecTxBuilder { 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 From 735a356cda0f90619f00713de90ddcd6a4cc9717 Mon Sep 17 00:00:00 2001 From: Leo Nash Date: Mon, 23 Mar 2026 03:38:05 +0000 Subject: [PATCH 050/135] Set the correct floor for the reserves in inbound V2 channels The floor for *our* selected reserve is *their* dust limit. Backport of a3dded178bbd00b58322153d3067dc98fbe9fe8d --- 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 02526d5a98b..184f43105ad 100644 --- a/lightning/src/ln/channel.rs +++ b/lightning/src/ln/channel.rs @@ -14131,9 +14131,9 @@ where 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 ef2a793994abbc4aa695fb78c653ea584d1fe537 Mon Sep 17 00:00:00 2001 From: Leo Nash Date: Mon, 15 Jun 2026 16:43:12 +0000 Subject: [PATCH 051/135] Make `FundedChannel::for_splice` fallible In preparation for an upcoming backport commit, we make `FundedChannel::for_splice` fallible. This will avoid introducing `expect` calls in the backport commit that aren't present in the upstream commit. --- lightning/src/ln/channel.rs | 28 ++++++++++++++++++---------- 1 file changed, 18 insertions(+), 10 deletions(-) diff --git a/lightning/src/ln/channel.rs b/lightning/src/ln/channel.rs index 184f43105ad..47ea22de791 100644 --- a/lightning/src/ln/channel.rs +++ b/lightning/src/ln/channel.rs @@ -2517,7 +2517,7 @@ 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 + ) -> Result where SP::Target: SignerProvider, { @@ -2532,9 +2532,15 @@ impl FundingScope { let post_value_to_self_msat = AddSigned::checked_add_signed( prev_funding.value_to_self_msat, 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(); + ) + .ok_or_else(|| { + // This should have been caught in `validate_splice_contributions` + debug_assert!(false); + String::from( + "Adding our funding contribution to our balance overflowed. \ + This should never happen! Please report this bug.", + ) + })?; let channel_parameters = &prev_funding.channel_transaction_parameters; let mut post_channel_transaction_parameters = ChannelTransactionParameters { @@ -2563,7 +2569,7 @@ impl FundingScope { context.counterparty_dust_limit_satoshis, ); - Self { + Ok(Self { channel_transaction_parameters: post_channel_transaction_parameters, value_to_self_msat: post_value_to_self_msat, funding_transaction: None, @@ -2587,7 +2593,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. @@ -12124,14 +12130,15 @@ where let mut new_keys = self.funding.get_holder_pubkeys().clone(); new_keys.funding_pubkey = funding_pubkey; - Ok(FundingScope::for_splice( + FundingScope::for_splice( &self.funding, &self.context, our_funding_contribution, their_funding_contribution, msg.funding_pubkey, new_keys, - )) + ) + .map_err(|e| ChannelError::WarnAndDisconnect(e)) } fn validate_splice_contributions( @@ -12385,14 +12392,15 @@ where let mut new_keys = self.funding.get_holder_pubkeys().clone(); new_keys.funding_pubkey = *new_holder_funding_key; - Ok(FundingScope::for_splice( + FundingScope::for_splice( &self.funding, &self.context, our_funding_contribution, their_funding_contribution, msg.funding_pubkey, new_keys, - )) + ) + .map_err(|e| ChannelError::WarnAndDisconnect(e)) } fn get_holder_counterparty_balances_floor_incl_fee( From 1cb1aae181a76a54ba5d4ca94e1b2ac709c42d3b Mon Sep 17 00:00:00 2001 From: Leo Nash Date: Wed, 8 Apr 2026 17:24:48 +0000 Subject: [PATCH 052/135] 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. Backport of 3835f842009cf1c317bb5cb166b0ae464e9088f4 Conflicts resolved in: * lightning/src/ln/channel.rs * lightning/src/ln/splicing_tests.rs * lightning/src/sign/tx_builder.rs --- lightning/src/ln/channel.rs | 85 ++++++++-- lightning/src/ln/splicing_tests.rs | 252 +++++++++++++++++++++++++++++ 2 files changed, 323 insertions(+), 14 deletions(-) diff --git a/lightning/src/ln/channel.rs b/lightning/src/ln/channel.rs index 47ea22de791..85ff467a9b8 100644 --- a/lightning/src/ln/channel.rs +++ b/lightning/src/ln/channel.rs @@ -2562,12 +2562,33 @@ 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 = - Some(get_v2_channel_reserve_satoshis(post_channel_value, MIN_CHAN_DUST_LIMIT_SATOSHIS)); + let counterparty_selected_channel_reserve_satoshis = Some( + get_v2_channel_reserve_satoshis(post_channel_value, context.holder_dust_limit_satoshis) + .map_err(|()| { + // This should have been caught in `validate_splice_contributions` + debug_assert!(false); + format!( + "The post-splice channel value {post_channel_value} \ + is smaller than our dust limit {}. \ + This should never happen! Please report this bug.", + context.holder_dust_limit_satoshis, + ) + })?, + ); let holder_selected_channel_reserve_satoshis = get_v2_channel_reserve_satoshis( post_channel_value, context.counterparty_dust_limit_satoshis, - ); + ) + .map_err(|()| { + // This should have been caught in `validate_splice_contributions` + debug_assert!(false); + format!( + "The post-splice channel value {post_channel_value} is smaller than \ + their dust limit {}. \ + This should never happen! Please report this bug.", + context.counterparty_dust_limit_satoshis, + ) + })?; Ok(Self { channel_transaction_parameters: post_channel_transaction_parameters, @@ -2991,6 +3012,9 @@ where /// 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"))] @@ -6472,14 +6496,21 @@ 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`. -fn get_v2_channel_reserve_satoshis(channel_value_satoshis: u64, dust_limit_satoshis: u64) -> u64 { +/// +/// Returns `Err` if `channel_value_satoshis` is smaller than `dust_limit_satoshis`. +fn get_v2_channel_reserve_satoshis( + channel_value_satoshis: u64, dust_limit_satoshis: u64, +) -> Result { + if channel_value_satoshis < dust_limit_satoshis { + return Err(()); + } // 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)) } fn check_splice_contribution_sufficient( @@ -12170,12 +12201,29 @@ where 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), + get_v2_channel_reserve_satoshis( + post_channel_value, + self.context.holder_dust_limit_satoshis, + ) + .map_err(|()| { + format!( + "The post-splice channel value {post_channel_value} is smaller than our dust limit {}", + self.context.holder_dust_limit_satoshis, + ) + })?, + ); + let holder_selected_channel_reserve = Amount::from_sat( + get_v2_channel_reserve_satoshis( + post_channel_value, + self.context.counterparty_dust_limit_satoshis, + ) + .map_err(|()| { + format!( + "The post-splice channel value {post_channel_value} is smaller than their dust limit {}", + self.context.counterparty_dust_limit_satoshis, + ) + })?, ); - let holder_selected_channel_reserve = Amount::from_sat(get_v2_channel_reserve_satoshis( - post_channel_value, - self.context.counterparty_dust_limit_satoshis, - )); // We allow parties to draw from their previous reserve, as long as they satisfy their v2 reserve @@ -13992,7 +14040,10 @@ where }); 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 + ).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) @@ -14139,9 +14190,15 @@ where 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).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); + channel_value_satoshis, their_dust_limit_satoshis + ).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 d8c71c0ea1a..1b04474526e 100644 --- a/lightning/src/ln/splicing_tests.rs +++ b/lightning/src/ln/splicing_tests.rs @@ -2119,3 +2119,255 @@ fn test_splice_with_inflight_htlc_forward_and_resolution() { do_test_splice_with_inflight_htlc_forward_and_resolution(true); do_test_splice_with_inflight_htlc_forward_and_resolution(false); } + +/// 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)] +#[derive(Clone, Copy, Debug)] +enum AcceptorBalance { + NoBalance, + BalanceInHTLC, + SettledBalance, +} + +#[cfg(test)] +#[derive(Clone, Copy, Debug)] +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.manually_accept_inbound_channels = true; + 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); + + provide_anchor_reserves(&nodes); + + // 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 = SpliceContribution::SpliceOut { outputs }; + + 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); + let post_splice_channel_value_sat = node_0_balance_leftover_amount.to_sat(); + // 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 = SpliceContribution::SpliceOut { outputs }; + + let res = nodes[0].node.splice_channel(&channel_id, &node_id_1, contribution, 253, None); + + match (validation_case, acceptor_balance) { + (ValidationCase::FailsAtHolder, AcceptorBalance::NoBalance) => { + let err = format!( + "The post-splice channel value {post_splice_channel_value_sat} \ + is smaller than our dust limit {high_dust_limit_satoshis}" + ); + assert_eq!(res.unwrap_err(), APIError::APIMisuseError { err }); + return; + }, + (ValidationCase::FailsAtHolder, _) => { + let v2_reserve_amount = Amount::from_sat(high_dust_limit_satoshis); + let err = format!( + "Channel {channel_id} cannot be spliced out; our \ + post-splice channel balance {node_0_balance_leftover_amount} \ + is smaller than their selected v2 reserve {v2_reserve_amount}" + ); + assert_eq!(res.unwrap_err(), APIError::APIMisuseError { err }); + return; + }, + _ => (), + } + + // The dummy contribution should have passed the holder's validation + assert!(res.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 cannot_splice_out = if matches!(acceptor_balance, AcceptorBalance::NoBalance) { + format!( + "Got non-closing error: 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); + } +} From 9755b2e890a09674b6fd196ab757eeed23d97ec3 Mon Sep 17 00:00:00 2001 From: Leo Nash Date: Fri, 12 Jun 2026 06:56:43 +0000 Subject: [PATCH 053/135] 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. Backport of 53e156a7613cdfc63df23a45df2e67380072b9b2 Conflicts resolved in: * lightning/src/ln/channel.rs * lightning/src/ln/channel_open_tests.rs * lightning/src/ln/functional_tests.rs * lightning/src/ln/htlc_reserve_unit_tests.rs * lightning/src/ln/update_fee_tests.rs Here their `dust_limit_satoshis` can never be greater than `MIN_THEIR_CHAN_RESERVE_SATOSHIS`, so by making sure that `channel_value_satoshis` is greater than `MIN_THEIR_CHAN_RESERVE_SATOSHIS`, we also make sure that `channel_value_satoshis` is greater than their `dust_limit_satoshis`. --- lightning/src/ln/channel.rs | 43 +++++++++++++++++---- 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 ++- 6 files changed, 49 insertions(+), 17 deletions(-) diff --git a/lightning/src/ln/channel.rs b/lightning/src/ln/channel.rs index 85ff467a9b8..d84eaa83ae8 100644 --- a/lightning/src/ln/channel.rs +++ b/lightning/src/ln/channel.rs @@ -6472,14 +6472,23 @@ fn get_holder_max_htlc_value_in_flight_msat( /// /// This is used both for outbound and inbound channels and has lower bound /// of `MIN_THEIR_CHAN_RESERVE_SATOSHIS`. +/// +/// Returns `Err` if `channel_value_satoshis` is smaller than +/// `MIN_THEIR_CHAN_RESERVE_SATOSHIS`. pub(crate) fn get_holder_selected_channel_reserve_satoshis( channel_value_satoshis: u64, config: &UserConfig, -) -> u64 { - let counterparty_chan_reserve_prop_mil = - config.channel_handshake_config.their_channel_reserve_proportional_millionths as u64; +) -> Result { + if channel_value_satoshis < MIN_THEIR_CHAN_RESERVE_SATOSHIS { + return Err(()); + } + // 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; - cmp::min(channel_value_satoshis, cmp::max(calculated_reserve, MIN_THEIR_CHAN_RESERVE_SATOSHIS)) + Ok(cmp::max(calculated_reserve, MIN_THEIR_CHAN_RESERVE_SATOSHIS)) } /// This is for legacy reasons, present for forward-compatibility. @@ -13426,7 +13435,8 @@ where F::Target: FeeEstimator, L::Target: Logger, { - let holder_selected_channel_reserve_satoshis = get_holder_selected_channel_reserve_satoshis(channel_value_satoshis, config); + let holder_selected_channel_reserve_satoshis = get_holder_selected_channel_reserve_satoshis(channel_value_satoshis, config) + .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 { // Protocol level safety check in place, although it should never happen because // of `MIN_THEIR_CHAN_RESERVE_SATOSHIS` @@ -13798,7 +13808,8 @@ where // 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, config) + .map_err(|()| ChannelError::close(format!("The channel value {} is smaller than {MIN_THEIR_CHAN_RESERVE_SATOSHIS}", msg.common_fields.funding_satoshis)))?; let counterparty_pubkeys = ChannelPublicKeys { funding_pubkey: msg.common_fields.funding_pubkey, revocation_basepoint: RevocationBasepoint::from(msg.common_fields.revocation_basepoint), @@ -16252,6 +16263,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] @@ -16271,7 +16286,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).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(); @@ -16281,7 +16308,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, /*is_0conf=*/false).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 3fd546aaff7..483cc5d793b 100644 --- a/lightning/src/ln/channel_open_tests.rs +++ b/lightning/src/ln/channel_open_tests.rs @@ -513,7 +513,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, &cfg); + get_holder_selected_channel_reserve_satoshis(channel_value_sat, &cfg).unwrap(); 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 36fb17ff076..95277b07d61 100644 --- a/lightning/src/ln/functional_tests.rs +++ b/lightning/src/ln/functional_tests.rs @@ -407,7 +407,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, &default_config).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 dc5d07c180e..533a1099741 100644 --- a/lightning/src/ln/htlc_reserve_unit_tests.rs +++ b/lightning/src/ln/htlc_reserve_unit_tests.rs @@ -48,7 +48,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, &default_config) * 1000; + push_amt -= + get_holder_selected_channel_reserve_satoshis(100_000, &default_config).unwrap() * 1000; let push = if send_from_initiator { 0 } else { push_amt }; let temp_channel_id = @@ -993,7 +994,8 @@ 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, &default_config).unwrap() * 1000; let _ = create_announced_chan_between_nodes_with_value(&nodes, 0, 1, 100_000, push_amt); @@ -1035,7 +1037,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, &default_config) * 1000; + push_amt -= + get_holder_selected_channel_reserve_satoshis(100_000, &default_config).unwrap() * 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 @@ -1111,7 +1114,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, &default_config) * 1000; + push_amt -= + get_holder_selected_channel_reserve_satoshis(100_000, &default_config).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 bab2a16bef9..58ccbbb3b1f 100644 --- a/lightning/src/ln/payment_tests.rs +++ b/lightning/src/ln/payment_tests.rs @@ -4951,7 +4951,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, &config).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 acb913294ab..35d02752fea 100644 --- a/lightning/src/ln/update_fee_tests.rs +++ b/lightning/src/ln/update_fee_tests.rs @@ -414,7 +414,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, &default_config); + get_holder_selected_channel_reserve_satoshis(channel_value, &default_config).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) @@ -892,7 +892,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, &default_config) * 1000; + push_amt -= + get_holder_selected_channel_reserve_satoshis(100_000, &default_config).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 dc410bda7f11ed765e83d9f782de9b2a04dcebd5 Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Tue, 17 Mar 2026 14:31:45 +0100 Subject: [PATCH 054/135] 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 Backport of 47e5c04f64492ade647652463dd6e3435f1aa815 Conflicts resolved in: * lightning-liquidity/src/lsps1/service.rs, which didn't support persistence in 0.2. --- lightning-liquidity/src/lsps2/service.rs | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/lightning-liquidity/src/lsps2/service.rs b/lightning-liquidity/src/lsps2/service.rs index 0cc0a4afd2d..25cc5dc8c18 100644 --- a/lightning-liquidity/src/lsps2/service.rs +++ b/lightning-liquidity/src/lsps2/service.rs @@ -1784,14 +1784,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 a95871285881e3bedc2d64b2814880a2dab05ab1 Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Wed, 10 Jun 2026 13:32:49 +0200 Subject: [PATCH 055/135] 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 Backport of c6f4d8fc0c54e770f5679176058b7a00c59bf541 --- 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 0496febc761..4180bf46f9b 100644 --- a/lightning/src/ln/async_payments_tests.rs +++ b/lightning/src/ln/async_payments_tests.rs @@ -2334,6 +2334,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 8c1887ad139..9c7652c782b 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 7321efc5023d7bc353813bb3a3f83101a55df035 Mon Sep 17 00:00:00 2001 From: Wilmer Paulino Date: Tue, 2 Jun 2026 11:48:32 -0700 Subject: [PATCH 056/135] 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. Backport of 27223fdda7039a01721f7289d218d70a52aabe31 Silent conflicts resolved in: * lightning/src/ln/async_signer_tests.rs --- 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 03728e28222..691a54d6e56 100644 --- a/lightning/src/ln/async_signer_tests.rs +++ b/lightning/src/ln/async_signer_tests.rs @@ -15,7 +15,7 @@ use bitcoin::secp256k1::Secp256k1; 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!(&nodes[1], &nodes[2], &updates.commitment_signed, false, true, false, true); + 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); + 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); + 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), + 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 d84eaa83ae8..eb7ab960056 100644 --- a/lightning/src/ln/channel.rs +++ b/lightning/src/ln/channel.rs @@ -9587,6 +9587,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 92cb67a6484e7782ac43f727701280e0b9173e0a Mon Sep 17 00:00:00 2001 From: Joost Jager Date: Tue, 16 Jun 2026 11:59:56 +0200 Subject: [PATCH 057/135] 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. Backport of 3c128ed8eccb1cfadd9615d56b710e67d84a5361 --- 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 f144f8b1231..c08274eeb7a 100644 --- a/lightning-liquidity/tests/lsps5_integration_tests.rs +++ b/lightning-liquidity/tests/lsps5_integration_tests.rs @@ -994,6 +994,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 214f83ded8eb6b9c375b445c5898c4b9546ba3ce Mon Sep 17 00:00:00 2001 From: tnull Date: Tue, 2 Jun 2026 14:20:45 +0200 Subject: [PATCH 058/135] 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 Backport of 6997c8886d683b6787645874f9f5f80db26c8164 --- .../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 003939d699d..81b5ce151d0 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 cd8eab9fecf7b11a1c1144e49243eecdd1000412 Mon Sep 17 00:00:00 2001 From: tnull Date: Tue, 2 Jun 2026 14:20:33 +0200 Subject: [PATCH 059/135] 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 Backport of 68e71c2f7385d70798b5442b0b05553385cb52be Trivial conflict resolved in: * lightning-liquidity/src/lsps2/service.rs --- 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 25cc5dc8c18..9c7335f8384 100644 --- a/lightning-liquidity/src/lsps2/service.rs +++ b/lightning-liquidity/src/lsps2/service.rs @@ -2361,6 +2361,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; @@ -2768,6 +2770,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 broadcast_not_allowed_after_non_paying_fee_payment_claimed() { let min_fee_msat: u64 = 12345; From 07f7fff9cbe9e676b472f9f15bef6956b6097043 Mon Sep 17 00:00:00 2001 From: Valentine Wallace Date: Mon, 15 Jun 2026 14:00:42 -0400 Subject: [PATCH 060/135] 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. Backport of e560ec170682d36e363361c6e8f09c958edd237b --- 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 37d7a1dba7d..b38d73ae699 100644 --- a/lightning/src/blinded_path/payment.rs +++ b/lightning/src/blinded_path/payment.rs @@ -692,7 +692,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; @@ -1125,4 +1125,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 d06d98c01975a3943e2bce1663b6ab192d9af0bc Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Tue, 16 Jun 2026 11:28:35 +0200 Subject: [PATCH 061/135] 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 Backport of 12815f380bc6339b815b0537bbae084a47e847c4 --- 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 de84ee20897..22cf8cc1d9c 100644 --- a/lightning-liquidity/src/manager.rs +++ b/lightning-liquidity/src/manager.rs @@ -673,7 +673,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. @@ -1289,7 +1289,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 15887f47f688516fd20e3f78906738a593f32baa Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Tue, 16 Jun 2026 11:28:44 +0200 Subject: [PATCH 062/135] 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 Backport of a1cda953c7614f9dcaa581047783705d9129185f --- 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 9c7335f8384..d09c2f827f7 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 2763104581e4eb06e8bc7872e4ece8c5804453cf Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Tue, 16 Jun 2026 11:28:55 +0200 Subject: [PATCH 063/135] 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 Backport of d75719120f8c918cfc798661b0768cd0382c3215 --- 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 e19477c187c..85e308fea80 100644 --- a/lightning-liquidity/src/lsps5/service.rs +++ b/lightning-liquidity/src/lsps5/service.rs @@ -339,6 +339,7 @@ where did_persist = true; } else { self.persist_peer_state(client_id).await?; + did_persist = true; } } From 2833bb0c235c0699f76059b2eed56ccf36bc0e0e Mon Sep 17 00:00:00 2001 From: Valentine Wallace Date: Wed, 17 Jun 2026 11:40:43 -0400 Subject: [PATCH 064/135] 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. Backport of 54cdd85fd44bd76a0f8da9b03ff3666561dbeb0d --- 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 bd88fa88bc3..d7a73cef4ca 100644 --- a/lightning/src/ln/channelmanager.rs +++ b/lightning/src/ln/channelmanager.rs @@ -5402,7 +5402,7 @@ where // 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 e1e3eb6a9f883fa4ff8c654561009de35034ab25 Mon Sep 17 00:00:00 2001 From: Jeffrey Czyz Date: Wed, 17 Jun 2026 09:28:21 -0500 Subject: [PATCH 065/135] 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) Backport of 77ac339b85d76ea1ae11b71c66098fceb979594f --- 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 90ea9982b652eaa3e855722a725bb3a2947fce96 Mon Sep 17 00:00:00 2001 From: Joost Jager Date: Thu, 18 Jun 2026 08:16:30 +0200 Subject: [PATCH 066/135] 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. Backport of ccc8b55f54930319e47676152ecd5bad727cafd9 Silent conflicts resolved in: * lightning-liquidity/tests/lsps2_integration_tests.rs --- lightning-liquidity/src/lsps2/service.rs | 36 +++--- .../tests/lsps2_integration_tests.rs | 120 ++++++++++++++++++ 2 files changed, 140 insertions(+), 16 deletions(-) diff --git a/lightning-liquidity/src/lsps2/service.rs b/lightning-liquidity/src/lsps2/service.rs index d09c2f827f7..70d1536e5be 100644 --- a/lightning-liquidity/src/lsps2/service.rs +++ b/lightning-liquidity/src/lsps2/service.rs @@ -1257,6 +1257,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 @@ -1292,25 +1294,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 9c42ee6908c..f66b3398670 100644 --- a/lightning-liquidity/tests/lsps2_integration_tests.rs +++ b/lightning-liquidity/tests/lsps2_integration_tests.rs @@ -696,6 +696,126 @@ 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.accept_intercept_htlcs = true; + + 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().to_byte_array()), + None, + Default::default(), + Retry::Attempts(0), + ) + .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 0c07f977ad10937eb1ebbd10faa8d7e84e3181a6 Mon Sep 17 00:00:00 2001 From: Matt Corallo Date: Tue, 16 Jun 2026 22:00:15 +0000 Subject: [PATCH 067/135] Draft release notes for 0.2.3 --- CHANGELOG.md | 86 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 86 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 33eb3a787e4..7cc8a113285 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,89 @@ +# 0.2.3 - Jun 18, 2026 - "Through the Loupe" + +## API Updates + * `DefaultMessageRouter` will now always generate blinded message paths that + provide no privacy (where our node is the introduction node) for nodes with + public channels. This works around an issue which will appear for any nodes + with LND peers that enable onion messaging - such peers will refuse to + forward BOLT 12 messages from unknown third parties, which most BOLT 12 + payers rely on today (#4647). + * Explicit `amount_msats` of 0 is rejected in BOLT 12 `Offer`s; `OfferBuilder` + now maps 0-amounts to an amount of `None` (#4324). + +## Bug Fixes + * `Features::supports_zero_conf` no longer clears the `ZeroConf` features and + `Features::requires_zero_conf` now correctly reports required, rather than + supported, status (#4517). + * If an MPP payment is claimed but `ChannelMonitorUpdate`s for some parts are + still being completed asynchronously, further channel updates (e.g. + forwarding another payment) are pending and the node restarts, the channel + could have become stuck (#4520). + * The presence of unconfirmed transactions actually no longer causes + `ElectrumSyncClient` to spuriously fail to sync (#4590). + * LSPS1, LSPS2, and LSPS5 persistence will no longer get stuck and refuse to + persist again after a single failure from the KVStore (#4597, #4282). + * Dropping the future returned by + `OutputSweeper::regenerate_and_broadcast_spend_if_necessary` no longer + results in future calls to the same method being spuriously ignored (#4598). + * Used async-receive offers are no longer refreshed on every timer tick once + their refresh time is reached (#4672). + * `FilesystemStore::list_all_keys` will no longer fail if there are stale + intermediate files lying around from a previous unclean shutdown (#4618). + * When forwarding an HTLC while in a blinded path with proportional fees over + 200%, LDK will no longer spuriously allow a forward that pays us 1 msat too + little in fees (#4697). + * Fixed a rare case where a channel could get stuck on reconnect when using + both async `ChannelMonitorUpdate` persistence and async signing (#4684). + * If we had exactly zero balance in a zero-fee-commitment channel, the + counterparty was able to splice all of their balance out, violating the + reserve requirements they'd otherwise be forced to keep (#4580). + * Providing an `Event::HTLCIntercepted` to the `LSPS2ServiceHandler` twice no + longer results in spuriously opening a channel early (#4656). + * `Event::PaymentSent::fee_paid_msat` is no longer `None` in cases where + `ChannelManager::abandon_payment` was called before the payment ultimately + completes anyway (#4651). + * `AnchorDescriptor::previous_utxo` now provides the correct `script_pubkey` + for non-zero-commitment-fee anchor channels (#4669). + * Syncing a `ChainMonitor` using the `Confirm` trait will no longer write some + full `ChannelMonitor`s to disk several times per block (#4544). + * `OMDomainResolver` now correctly accounts for failed queries when rate + limiting, ensuring we continue to respond to queries after failures (#4591). + * Calling `ChannelManager::send_payment_with_route` without a `route_params` + and with an invalid `Route` will no longer panic (#4707). + * `LSPS2ServiceHandler::channel_open_failed` now correctly fails intercepted + HTLCs rather than allowing them to fail just before expiry (#4677). + * `StaticInvoice::is_offer_expired` was corrected to check offer, rather than + static invoice, expiry (#4594). + * `lightning-custom-message`'s handling of `peer_connected` events now ensures + that sub-handlers will see a `peer_disconnected` event if a different + sub-handler refused the connection by `Err`ing `peer_connected` (#4595). + * Replay protection for LSPS5 signatures now detects replays which are only + different in the encoded signature's case (#4701). + * When `lightning-liquidity` is configured in the background processor, there + is no longer a stream of `Persisting LiquidityManager...` log spam (#4246). + * Incomplete MPP keysend payments will no longer see their HTLCs held until + expiry (#4558). + * `InvoiceRequestBuilder` will no longer accept a `quantity` of `0` for a + BOLT 12 `Offer`, allowing any quantity up to a bound (#4667). + * `lightning-custom-message` handlers that return `Ok(None)` when asked to + deserialize a message in their defined range no longer cause panics (#4709). + * Several spurious debug assertions were fixed (#4537, #4618, #4026) + +## Security +0.2.3 fixes several underestimates of the anchor reserves required to ensure we +can reliably close channels and a sanitization issue. + * When using the `anchor_channel_reserves` module to calculate reserves + required to pay for fees when closing anchor channels, zero-fee-commitment + channels were not considered. This could allow a counterparty to open many + channels, leaving us unable to properly force-close (#4592). + * The `anchor_channel_reserves` module overestimated the value of `Utxo`s in + the wallet by ignoring the `TxIn` cost to spend them (#4670). + * `PrintableString` did not properly sanitize unicode format characters, + allowing an attacker to corrupt the rendering of logs or UI (#4593, #4605). + +Thanks to Project Loupe for reporting most of the issues fixed in this release. + + # 0.2.2 - Feb 6, 2025 - "An Async Splicing Production" ## API Updates From 2a38c47a5c888a10bf470000524fe0c25d95e4aa Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Wed, 10 Jun 2026 14:42:54 +0200 Subject: [PATCH 068/135] 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 Backport of 06393eba2d2f12f13ff7a79149ec76b9895db787 Conflicts resolved in: * lightning/src/routing/router.rs --- lightning-invoice/src/lib.rs | 53 ++++++++++++++++++++++++++--- lightning/src/ln/invoice_utils.rs | 4 +-- lightning/src/routing/router.rs | 55 +++++++++++++++++++++++++++++-- 3 files changed, 103 insertions(+), 9 deletions(-) diff --git a/lightning-invoice/src/lib.rs b/lightning-invoice/src/lib.rs index 47f929377de..34014594543 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 + }, } } @@ -2044,6 +2049,44 @@ mod test { assert!(new_signed.check_signature()); } + #[test] + fn recover_payee_pub_key_uses_included_payee_pub_key() { + use crate::*; + 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(sha256::Hash::from_slice(&[0; 32][..]).unwrap()) + .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 7c0190a23a9..1258e699114 100644 --- a/lightning/src/ln/invoice_utils.rs +++ b/lightning/src/ln/invoice_utils.rs @@ -1288,7 +1288,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()) @@ -1350,7 +1350,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 8ea3ea068b3..c9f37006b9c 100644 --- a/lightning/src/routing/router.rs +++ b/lightning/src/routing/router.rs @@ -980,7 +980,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()) @@ -3922,8 +3922,10 @@ 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::hashes::{Hash, sha256::Hash as Sha256}; use bitcoin::hex::FromHex; use bitcoin::network::Network; use bitcoin::opcodes; @@ -3932,9 +3934,58 @@ mod tests { use bitcoin::secp256k1::{PublicKey, SecretKey}; use bitcoin::transaction::TxOut; + use lightning_invoice::{Bolt11Bech32, Bolt11Invoice, Currency, InvoiceBuilder}; + use crate::io::Cursor; use crate::prelude::*; use crate::sync::Arc; + use crate::types::payment::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(Sha256::from_slice(&[0; 32][..]).unwrap()) + .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 { + super::Payee::Clear { node_id, .. } => assert_eq!(node_id, public_key), + super::Payee::Blinded { .. } => panic!("BOLT11 invoice should create a clear payee"), + } + } #[rustfmt::skip] fn get_channel_details(short_channel_id: Option, node_id: PublicKey, From ec9675591b2d38d60524a8aba838e78ede947042 Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Wed, 20 May 2026 10:24:41 +0200 Subject: [PATCH 069/135] 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 Backport of beffe75a323f00e7c2eeff02497ee40cfc1002e5 --- 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 c9f37006b9c..12ba06eff64 100644 --- a/lightning/src/routing/router.rs +++ b/lightning/src/routing/router.rs @@ -2231,10 +2231,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(); @@ -2250,7 +2252,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) @@ -2267,7 +2270,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; } } @@ -3448,7 +3463,17 @@ where L::Target: Logger { // 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); @@ -9095,6 +9120,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 60c09e08b69f8e6ab8a05469f8665c68d63d9185 Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Thu, 19 Mar 2026 13:07:04 +0100 Subject: [PATCH 070/135] 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 Backport of e2f611e91b3f6edb4345e59656affef8a3a303d0 Conflicts resolved in: * lightning/src/ln/peer_handler.rs --- lightning/src/ln/channel.rs | 9 ++-- lightning/src/ln/peer_handler.rs | 13 +++-- lightning/src/util/macro_logger.rs | 85 ++++++++++++++++++++++++++++++ 3 files changed, 97 insertions(+), 10 deletions(-) diff --git a/lightning/src/ln/channel.rs b/lightning/src/ln/channel.rs index eb7ab960056..78dd7adf23a 100644 --- a/lightning/src/ln/channel.rs +++ b/lightning/src/ln/channel.rs @@ -1959,9 +1959,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 74f081b03ae..30f8ec35d88 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}; @@ -2415,7 +2414,7 @@ where 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() { @@ -2427,7 +2426,7 @@ where logger, "Got warning message from {}: {}", their_node_id, - PrintableString(&msg.data) + log_msg!(msg.data) ); }, @@ -3213,7 +3212,7 @@ where msgs::ErrorAction::DisconnectPeer { msg } => { if let Some(msg) = msg.as_ref() { log_trace!(logger, "Handling DisconnectPeer HandleError event in peer_handler for node {} with message {}", - node_id, msg.data); + node_id, log_msg!(msg.data)); } else { log_trace!(logger, "Handling DisconnectPeer HandleError event in peer_handler for node {}", node_id); @@ -3228,7 +3227,7 @@ where }, msgs::ErrorAction::DisconnectPeerWithWarning { msg } => { log_trace!(logger, "Handling DisconnectPeer HandleError event in peer_handler for node {} with message {}", - node_id, msg.data); + node_id, 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. @@ -3254,7 +3253,7 @@ where msgs::ErrorAction::SendErrorMessage { ref msg } => { log_trace!(logger, "Handling SendErrorMessage HandleError event in peer_handler for node {} with message {}", node_id, - msg.data); + log_msg!(msg.data)); self.enqueue_message( &mut *get_peer_for_forwarding!(&node_id)?, msg, @@ -3266,7 +3265,7 @@ where } => { log_given_level!(logger, *log_level, "Handling SendWarningMessage HandleError event in peer_handler for node {} with message {}", node_id, - msg.data); + log_msg!(msg.data)); self.enqueue_message( &mut *get_peer_for_forwarding!(&node_id)?, msg, diff --git a/lightning/src/util/macro_logger.rs b/lightning/src/util/macro_logger.rs index ec9eb14ba38..fac68f19c59 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 518125d788bd9eb7dc9ecb934cb895b5cd2835ea Mon Sep 17 00:00:00 2001 From: Matt Corallo Date: Sun, 12 Apr 2026 21:07:19 +0000 Subject: [PATCH 071/135] 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 Backport of 7a89362c4ae3eb97a4b3e7138146da7a67a7fc38 Conflicts resolved in: * lightning/src/routing/gossip.rs --- lightning-rapid-gossip-sync/src/lib.rs | 12 +++++++ lightning-rapid-gossip-sync/src/processing.rs | 34 ++++++++++++++++--- lightning/src/routing/gossip.rs | 18 +++++----- 3 files changed, 52 insertions(+), 12 deletions(-) diff --git a/lightning-rapid-gossip-sync/src/lib.rs b/lightning-rapid-gossip-sync/src/lib.rs index 429a3560be0..374cffd8330 100644 --- a/lightning-rapid-gossip-sync/src/lib.rs +++ b/lightning-rapid-gossip-sync/src/lib.rs @@ -153,6 +153,10 @@ where /// 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 @@ -172,6 +176,10 @@ where /// 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 { @@ -182,6 +190,10 @@ where /// 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 8319506b574..ad58c74eb54 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}; @@ -115,17 +117,27 @@ where } }; + 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)?; @@ -237,9 +249,12 @@ where } } + 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 @@ -284,6 +299,10 @@ where } } + 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, @@ -329,6 +348,13 @@ where 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 80ffbf9fb6c..51a9a59e47f 100644 --- a/lightning/src/routing/gossip.rs +++ b/lightning/src/routing/gossip.rs @@ -1757,14 +1757,16 @@ where } } -// In Jan, 2025 there were about 49K channels. -// We over-allocate by a bit because 20% more is better than the double we get if we're slightly -// too low -const CHAN_COUNT_ESTIMATE: usize = 60_000; -// In Jan, 2025 there were about 15K nodes -// We over-allocate by a bit because 33% more is better than the double we get if we're slightly -// too low -const NODE_COUNT_ESTIMATE: usize = 20_000; +/// In Jan, 2025 there were about 49K channels. +/// +/// We over-allocate by a bit because 20% more is better than the double we get if we're slightly +/// too low +pub const CHAN_COUNT_ESTIMATE: usize = 60_000; +/// In Jan, 2025 there were about 15K nodes +/// +/// We over-allocate by a bit because 33% more is better than the double we get if we're slightly +/// too low +pub const NODE_COUNT_ESTIMATE: usize = 20_000; impl NetworkGraph where From 70ccee1a198c67e16a86f0aec9d121277600854f Mon Sep 17 00:00:00 2001 From: Matt Corallo Date: Wed, 1 Apr 2026 23:50:34 +0000 Subject: [PATCH 072/135] 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. Backport of 5b4626fa716b5a2dbd4eff9a83301f55867ee43e --- 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 f821aa5afc0..92b1e224ac6 100644 --- a/lightning/src/util/ser.rs +++ b/lightning/src/util/ser.rs @@ -925,7 +925,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 ab2688d6886cad033297e739a023268dc1f73276 Mon Sep 17 00:00:00 2001 From: Matt Corallo Date: Sat, 11 Apr 2026 11:22:08 +0000 Subject: [PATCH 073/135] 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 Backport of ae852b58a7fa0026a042f35a66990363cb97bce4 --- 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 54eb16b5266..c88526c0932 100644 --- a/lightning/src/onion_message/dns_resolution.rs +++ b/lightning/src/onion_message/dns_resolution.rs @@ -520,7 +520,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 c47d8748e323a6107e7a32e3e39eb7f115f054e9 Mon Sep 17 00:00:00 2001 From: Matt Corallo Date: Tue, 16 Jun 2026 01:02:34 +0000 Subject: [PATCH 074/135] 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 Backport of b7c9935be7d59290c11b27967021c486b65b046d --- 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 30533af2a911aa59219b054183976408861fd7a5 Mon Sep 17 00:00:00 2001 From: Matt Corallo Date: Thu, 18 Jun 2026 21:52:46 +0000 Subject: [PATCH 075/135] Update 0.2.3 changelog entry for security issues --- CHANGELOG.md | 21 ++++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7cc8a113285..694027f43d1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -71,7 +71,19 @@ ## Security 0.2.3 fixes several underestimates of the anchor reserves required to ensure we -can reliably close channels and a sanitization issue. +can reliably close channels, several denial-of-service vulnerabilities and a +sanitization issue. + * `Bolt11Invoice::recover_payee_pub_key` no longer panics if called on an + invoice which set an explicit public key, rather than relying on public key + recovery. Note that this method is called from + `PaymentParameters::from_bolt11_invoice` (#4717). + * Maliciously-crafted unpayable invoices which have overflowing feerates will + no longer cause an `unwrap` failure panic (#4716). + * `possiblyrandom` did not properly generate random data except when it was + explicitly configured to. By default this means LDK is vulnerable to various + HashDoS attacks (#4719). + * `OMNameResolver` will no longer panic when looking up payment instructions + which include unicode characters at the start of a TXT record (#4718). * When using the `anchor_channel_reserves` module to calculate reserves required to pay for fees when closing anchor channels, zero-fee-commitment channels were not considered. This could allow a counterparty to open many @@ -80,6 +92,13 @@ can reliably close channels and a sanitization issue. the wallet by ignoring the `TxIn` cost to spend them (#4670). * `PrintableString` did not properly sanitize unicode format characters, allowing an attacker to corrupt the rendering of logs or UI (#4593, #4605). + * RGS data is now limited in how large of a graph it is able to cause a client + to store in memory. Note that RGS data is still considered a DoS vector in + general and you should only use semi-trusted RGS data (#4713). + * Counterparty-provided strings in failure messages are no longer logged in + full, reducing the ability of such a counterparty to spam our logs (#4714). + * Reading a corrupted `ChannelManager` or `ProbabilisticScorer` can no longer + cause us to allocate large amounts of memory (#4712). Thanks to Project Loupe for reporting most of the issues fixed in this release. From 9ed8a8d214e75e87be7d8ac1ba93d68cb2215124 Mon Sep 17 00:00:00 2001 From: Matt Corallo Date: Thu, 18 Jun 2026 21:56:59 +0000 Subject: [PATCH 076/135] Bump to 0.2.3/dns-resolver 0.3.1/invoice 0.34.1/types 0.3.2 --- lightning-background-processor/Cargo.toml | 2 +- lightning-custom-message/Cargo.toml | 2 +- lightning-dns-resolver/Cargo.toml | 2 +- lightning-invoice/Cargo.toml | 2 +- lightning-liquidity/Cargo.toml | 2 +- lightning-persister/Cargo.toml | 2 +- lightning-rapid-gossip-sync/Cargo.toml | 2 +- lightning-types/Cargo.toml | 2 +- lightning/Cargo.toml | 2 +- possiblyrandom/Cargo.toml | 2 +- 10 files changed, 10 insertions(+), 10 deletions(-) diff --git a/lightning-background-processor/Cargo.toml b/lightning-background-processor/Cargo.toml index 828a8017574..ffe0b4ce743 100644 --- a/lightning-background-processor/Cargo.toml +++ b/lightning-background-processor/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "lightning-background-processor" -version = "0.2.0" +version = "0.2.3" authors = ["Valentine Wallace "] license = "MIT OR Apache-2.0" repository = "https://github.com/lightningdevkit/rust-lightning" diff --git a/lightning-custom-message/Cargo.toml b/lightning-custom-message/Cargo.toml index 96632f24bc1..8c1bcced08c 100644 --- a/lightning-custom-message/Cargo.toml +++ b/lightning-custom-message/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "lightning-custom-message" -version = "0.2.0" +version = "0.2.3" authors = ["Jeffrey Czyz"] license = "MIT OR Apache-2.0" repository = "https://github.com/lightningdevkit/rust-lightning" diff --git a/lightning-dns-resolver/Cargo.toml b/lightning-dns-resolver/Cargo.toml index 248cb73025a..bdc13f5e41b 100644 --- a/lightning-dns-resolver/Cargo.toml +++ b/lightning-dns-resolver/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "lightning-dns-resolver" -version = "0.3.0" +version = "0.3.1" authors = ["Matt Corallo"] license = "MIT OR Apache-2.0" repository = "https://github.com/lightningdevkit/rust-lightning/" diff --git a/lightning-invoice/Cargo.toml b/lightning-invoice/Cargo.toml index 30bbfa9a3be..ecd1552cfbc 100644 --- a/lightning-invoice/Cargo.toml +++ b/lightning-invoice/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "lightning-invoice" description = "Data structures to parse and serialize BOLT11 lightning invoices" -version = "0.34.0" +version = "0.34.1" authors = ["Sebastian Geisler "] documentation = "https://docs.rs/lightning-invoice/" license = "MIT OR Apache-2.0" diff --git a/lightning-liquidity/Cargo.toml b/lightning-liquidity/Cargo.toml index a4855957f7a..4d1f8616e6d 100644 --- a/lightning-liquidity/Cargo.toml +++ b/lightning-liquidity/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "lightning-liquidity" -version = "0.2.0" +version = "0.2.3" authors = ["John Cantrell ", "Elias Rohrer "] homepage = "https://lightningdevkit.org/" license = "MIT OR Apache-2.0" diff --git a/lightning-persister/Cargo.toml b/lightning-persister/Cargo.toml index cdd4b3a5086..dfea46d0d91 100644 --- a/lightning-persister/Cargo.toml +++ b/lightning-persister/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "lightning-persister" -version = "0.2.0" +version = "0.2.3" authors = ["Valentine Wallace", "Matt Corallo"] license = "MIT OR Apache-2.0" repository = "https://github.com/lightningdevkit/rust-lightning" diff --git a/lightning-rapid-gossip-sync/Cargo.toml b/lightning-rapid-gossip-sync/Cargo.toml index 695e41a3662..54a73a00f78 100644 --- a/lightning-rapid-gossip-sync/Cargo.toml +++ b/lightning-rapid-gossip-sync/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "lightning-rapid-gossip-sync" -version = "0.2.0" +version = "0.2.3" authors = ["Arik Sosman "] license = "MIT OR Apache-2.0" repository = "https://github.com/lightningdevkit/rust-lightning" diff --git a/lightning-types/Cargo.toml b/lightning-types/Cargo.toml index 32552def61d..588eb151742 100644 --- a/lightning-types/Cargo.toml +++ b/lightning-types/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "lightning-types" -version = "0.3.1" +version = "0.3.2" authors = ["Matt Corallo"] license = "MIT OR Apache-2.0" repository = "https://github.com/lightningdevkit/rust-lightning/" diff --git a/lightning/Cargo.toml b/lightning/Cargo.toml index 2e0ddd389ed..a4a91dfb1eb 100644 --- a/lightning/Cargo.toml +++ b/lightning/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "lightning" -version = "0.2.2" +version = "0.2.3" authors = ["Matt Corallo"] license = "MIT OR Apache-2.0" repository = "https://github.com/lightningdevkit/rust-lightning/" diff --git a/possiblyrandom/Cargo.toml b/possiblyrandom/Cargo.toml index 4508f690129..91c0f201238 100644 --- a/possiblyrandom/Cargo.toml +++ b/possiblyrandom/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "possiblyrandom" -version = "0.2.0" +version = "0.2.1" authors = ["Matt Corallo"] license = "MIT OR Apache-2.0" repository = "https://github.com/lightningdevkit/rust-lightning/" From fe9a92de0f88066c68632c563a82d89115be892a Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Tue, 5 May 2026 20:48:43 +0200 Subject: [PATCH 077/135] 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 Backport of 837763a6179a31fad25f4f33984b0da837ee8bd4 Conflicts resolved in: * lightning-liquidity/src/lsps0/ser.rs --- 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 70649fe0f50..2f62ae24cc8 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); @@ -271,8 +271,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")) } } @@ -981,4 +996,15 @@ mod tests { let decoded_datetime: LSPSDateTime = Readable::read(&mut Cursor::new(buf)).unwrap(); assert_eq!(expected_datetime, decoded_datetime); } + + #[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 f9183f1ceeadc14c59d8cdf72cbf81ba4d9bb5bb Mon Sep 17 00:00:00 2001 From: Matt Corallo Date: Fri, 19 Jun 2026 01:38:18 +0000 Subject: [PATCH 078/135] Add missing CHANGELOG entry for 4715 --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 694027f43d1..9ec8c31932a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -79,6 +79,8 @@ sanitization issue. `PaymentParameters::from_bolt11_invoice` (#4717). * Maliciously-crafted unpayable invoices which have overflowing feerates will no longer cause an `unwrap` failure panic (#4716). + * Parsing an `LSPSDateTime` which is before 1970 no longer panics. This is + reachable when parsing messages from counterparties (#4715). * `possiblyrandom` did not properly generate random data except when it was explicitly configured to. By default this means LDK is vulnerable to various HashDoS attacks (#4719). From 6fd602fa7f53b756ac6891ebc61cb69d7f093d0f Mon Sep 17 00:00:00 2001 From: Matt Corallo Date: Tue, 23 Jun 2026 00:22:16 +0000 Subject: [PATCH 079/135] Fix MSRV breakagae introduced in backports Several backports broke MSRV for 0.2 which is still 1.63. We fix it here. --- lightning/src/chain/chainmonitor.rs | 2 +- lightning/src/ln/splicing_tests.rs | 2 +- lightning/src/util/sweep.rs | 3 +-- 3 files changed, 3 insertions(+), 4 deletions(-) diff --git a/lightning/src/chain/chainmonitor.rs b/lightning/src/chain/chainmonitor.rs index b060c9b0601..7c96d918e34 100644 --- a/lightning/src/chain/chainmonitor.rs +++ b/lightning/src/chain/chainmonitor.rs @@ -579,7 +579,7 @@ 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).map(|key| key % partition_factor == 0).unwrap_or(false) { log_trace!( logger, "Syncing Channel Monitor for channel {}", diff --git a/lightning/src/ln/splicing_tests.rs b/lightning/src/ln/splicing_tests.rs index 1b04474526e..f9d3495b38f 100644 --- a/lightning/src/ln/splicing_tests.rs +++ b/lightning/src/ln/splicing_tests.rs @@ -2358,7 +2358,7 @@ fn do_test_splice_out_initiator_reserve_breach_zero_fee_commitments( // 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(), + (channel_value_sat as i64 + funding_contribution_sat) as u64, high_dust_limit_satoshis ); format!( diff --git a/lightning/src/util/sweep.rs b/lightning/src/util/sweep.rs index a10ecdc01e2..eb1fc09f2c2 100644 --- a/lightning/src/util/sweep.rs +++ b/lightning/src/util/sweep.rs @@ -1132,7 +1132,6 @@ mod tests { 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; @@ -1238,7 +1237,7 @@ mod tests { // `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 mut fut = Box::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)); From 6beb0525aef2da4cc0fa4ce44151dfff72b6abd2 Mon Sep 17 00:00:00 2001 From: Matt Corallo Date: Thu, 25 Jun 2026 18:47:31 +0000 Subject: [PATCH 080/135] Fix unused imports warnings introduced in backports --- lightning/src/ln/offers_tests.rs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/lightning/src/ln/offers_tests.rs b/lightning/src/ln/offers_tests.rs index e8832316b9a..300066c5c5f 100644 --- a/lightning/src/ln/offers_tests.rs +++ b/lightning/src/ln/offers_tests.rs @@ -62,9 +62,8 @@ use crate::offers::nonce::Nonce; use crate::offers::parse::Bolt12SemanticError; use crate::onion_message::messenger::{DefaultMessageRouter, Destination, MessageSendInstructions, NodeIdMessageRouter, NullMessageRouter, PeeledOnion, PADDED_PATH_LENGTH}; use crate::onion_message::offers::OffersMessage; -use crate::routing::gossip::{NodeAlias, NodeId}; use crate::routing::router::{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 From f96e94d562377031c01b1010cc15bf2146d75881 Mon Sep 17 00:00:00 2001 From: Matt Corallo Date: Tue, 23 Jun 2026 00:32:44 +0000 Subject: [PATCH 081/135] Update MSRV compatibility pins in CI --- .github/workflows/build.yml | 6 ++++++ ci/ci-tests.sh | 18 ++++++++++++++++++ ci/ci-tx-sync-tests.sh | 6 ++++++ fuzz/ci-fuzz.sh | 2 +- 4 files changed, 31 insertions(+), 1 deletion(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 16c6c5b4f08..c3c61fb1a34 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -266,9 +266,15 @@ jobs: cd fuzz && cargo update -p regex --precise "1.9.6" && cargo update -p syn --precise "2.0.106" && cargo update -p quote --precise "1.0.41" cargo update -p proc-macro2 --precise "1.0.103" --verbose && cargo update -p serde_json --precise "1.0.145" --verbose cargo update -p itoa --precise "1.0.15" --verbose && cargo update -p ryu --precise "1.0.20" --verbose + cargo update -p libc --precise 0.2.183 + cargo update -p unicode-ident --precise 1.0.22 + cargo update -p honggfuzz --precise 0.5.59 + cargo update -p semver --precise 1.0.27 cd write-seeds && cargo update -p regex --precise "1.9.6" && cargo update -p syn --precise "2.0.106" && cargo update -p quote --precise "1.0.41" cargo update -p proc-macro2 --precise "1.0.103" --verbose && cargo update -p serde_json --precise "1.0.145" --verbose cargo update -p itoa --precise "1.0.15" --verbose && cargo update -p ryu --precise "1.0.20" --verbose + cargo update -p libc --precise 0.2.183 + cargo update -p unicode-ident --precise 1.0.22 - name: Sanity check fuzz targets on Rust ${{ env.TOOLCHAIN }} run: | cd fuzz diff --git a/ci/ci-tests.sh b/ci/ci-tests.sh index eb7dfd58826..fc2d61e3f1e 100755 --- a/ci/ci-tests.sh +++ b/ci/ci-tests.sh @@ -29,6 +29,12 @@ function PIN_RELEASE_DEPS { # Starting with version 1.0.21, the `ryu` crate has an MSRV of rustc 1.68 [ "$RUSTC_MINOR_VERSION" -lt 68 ] && cargo update -p ryu --precise "1.0.20" --verbose + # Starting with version 1.0.23, the `unicode-ident` crate has an MSRV of rustc 1.71 + [ "$RUSTC_MINOR_VERSION" -lt 71 ] && cargo update -p unicode-ident --precise 1.0.22 + + # Starting with version 0.2.184, the `libc` crate has an MSRV of rustc 1.65 + [ "$RUSTC_MINOR_VERSION" -lt 65 ] && cargo update -p libc --precise 0.2.183 --verbose + return 0 # Don't fail the script if our rustc is higher than the last check } @@ -55,6 +61,13 @@ PIN_RELEASE_DEPS # pin the release dependencies in our main workspace # lock_api 0.4.13 requires rustc 1.64.0 [ "$RUSTC_MINOR_VERSION" -lt 64 ] && cargo update -p lock_api --precise "0.4.12" --verbose +# inventory 0.3.22 requires rustc 1.68.0 +[ "$RUSTC_MINOR_VERSION" -lt 68 ] && cargo update -p inventory --precise "0.3.21" --verbose + +# Starting with version 0.4.0, the `getrandom` crate has an MSRV of rustc 1.85 +GETRANDOM_VERSION="$(cargo tree 2>&1 | grep -o 'getrandom v0.4.*' | tr -d ' `' | tr 'v' '@' || echo -n)" +[ "$RUSTC_MINOR_VERSION" -lt 85 ] && cargo update -p "$GETRANDOM_VERSION" --precise 0.3.4 --verbose + export RUST_BACKTRACE=1 echo -e "\n\nChecking the workspace, except lightning-transaction-sync." @@ -71,6 +84,8 @@ pushd lightning-tests [ "$RUSTC_MINOR_VERSION" -lt 68 ] && cargo update -p syn --precise "2.0.106" --verbose [ "$RUSTC_MINOR_VERSION" -lt 68 ] && cargo update -p quote --precise "1.0.41" --verbose [ "$RUSTC_MINOR_VERSION" -lt 68 ] && cargo update -p proc-macro2 --precise "1.0.103" --verbose +[ "$RUSTC_MINOR_VERSION" -lt 71 ] && cargo update -p unicode-ident --precise 1.0.22 +[ "$RUSTC_MINOR_VERSION" -lt 65 ] && cargo update -p libc --precise 0.2.183 --verbose cargo test popd @@ -147,6 +162,8 @@ pushd no-std-check [ "$RUSTC_MINOR_VERSION" -lt 68 ] && cargo update -p serde_json --precise "1.0.145" --verbose [ "$RUSTC_MINOR_VERSION" -lt 68 ] && cargo update -p itoa --precise "1.0.15" --verbose [ "$RUSTC_MINOR_VERSION" -lt 68 ] && cargo update -p ryu --precise "1.0.20" --verbose +[ "$RUSTC_MINOR_VERSION" -lt 71 ] && cargo update -p unicode-ident --precise "1.0.22" --verbose +[ "$RUSTC_MINOR_VERSION" -lt 65 ] && cargo update -p libc --precise "0.2.183" --verbose cargo check --verbose --color always [ "$CI_MINIMIZE_DISK_USAGE" != "" ] && cargo clean popd @@ -160,6 +177,7 @@ popd if [ -f "$(which arm-none-eabi-gcc)" ]; then pushd no-std-check + [ "$RUSTC_MINOR_VERSION" -lt 71 ] && cargo update -p unicode-ident --precise "1.0.22" --verbose cargo build --target=thumbv7m-none-eabi [ "$CI_MINIMIZE_DISK_USAGE" != "" ] && cargo clean popd diff --git a/ci/ci-tx-sync-tests.sh b/ci/ci-tx-sync-tests.sh index 5f926a8e37b..a67f0bc88de 100755 --- a/ci/ci-tx-sync-tests.sh +++ b/ci/ci-tx-sync-tests.sh @@ -20,6 +20,12 @@ PIN_RELEASE_DEPS # pin the release dependencies # 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" --verbose +# Starting with version 1.9.0, the `zeroize` crate has an MSRV of rustc 1.85. +[ "$RUSTC_MINOR_VERSION" -lt 85 ] && cargo update -p zeroize --precise "1.8.2" --verbose + +# Starting with version 0.27.8, the `hyper-rustls` crate has an MSRV of rustc 1.85. +[ "$RUSTC_MINOR_VERSION" -lt 85 ] && cargo update -p hyper-rustls --precise "0.27.7" --verbose + export RUST_BACKTRACE=1 echo -e "\n\nChecking Transaction Sync Clients with features." diff --git a/fuzz/ci-fuzz.sh b/fuzz/ci-fuzz.sh index d1274d751a8..92d689f2175 100755 --- a/fuzz/ci-fuzz.sh +++ b/fuzz/ci-fuzz.sh @@ -21,7 +21,7 @@ RUSTFLAGS="$RUSTFLAGS --cfg=fuzzing" cargo run ../hfuzz_workspace/full_stack_tar cargo clean popd -cargo install --color always --force honggfuzz --no-default-features +cargo install --color always --force honggfuzz --no-default-features --version "=0.5.59" --locked # Because we're fuzzing relatively few iterations, the maximum possible # compiler optimizations aren't necessary, so we turn off LTO From c856ca5b4c2310b4c18d9b85fbc37438daa3405f Mon Sep 17 00:00:00 2001 From: Matt Corallo Date: Thu, 25 Jun 2026 16:38:31 +0000 Subject: [PATCH 082/135] Cut 0.2.4 --- CHANGELOG.md | 7 +++++++ lightning/Cargo.toml | 2 +- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9ec8c31932a..564ad4768de 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,10 @@ +# 0.2.4 - Jun 25, 2026 - "Release the CI" + +## Bug Fixes + * 0.2.3 broke the MSRV of the `lightning` crate, which now again compiles on + `rustc` 1.63 (#4737). + + # 0.2.3 - Jun 18, 2026 - "Through the Loupe" ## API Updates diff --git a/lightning/Cargo.toml b/lightning/Cargo.toml index a4a91dfb1eb..779a494df48 100644 --- a/lightning/Cargo.toml +++ b/lightning/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "lightning" -version = "0.2.3" +version = "0.2.4" authors = ["Matt Corallo"] license = "MIT OR Apache-2.0" repository = "https://github.com/lightningdevkit/rust-lightning/" From 6c19283f19c57e0e35cbe7b11430f8951e4e686d Mon Sep 17 00:00:00 2001 From: Vincenzo Palazzo Date: Mon, 27 Jul 2026 01:25:18 +0200 Subject: [PATCH 083/135] 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. Backport of 0c352c555eea15dbe19205783cdbcb02ff6fccb2 Silent conflicts resolved in: * lightning/src/blinded_path/message.rs --- lightning/src/blinded_path/message.rs | 160 ++++++++++++++++++++++++++ 1 file changed, 160 insertions(+) diff --git a/lightning/src/blinded_path/message.rs b/lightning/src/blinded_path/message.rs index ed55ca5dc9b..a2d92fdb7d9 100644 --- a/lightning/src/blinded_path/message.rs +++ b/lightning/src/blinded_path/message.rs @@ -128,10 +128,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 { @@ -731,3 +739,155 @@ pub(super) fn blinded_hops( 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, 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()), + 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 12e93fe073643d784eaaf0ab1950aedc25d4aa7e Mon Sep 17 00:00:00 2001 From: Vincenzo Palazzo Date: Tue, 4 Aug 2026 20:27:50 +0200 Subject: [PATCH 084/135] lightning-types: replace Zl/Zp separators in `PrintableString` `PrintableString` replaces control (`Cc`), "other" (`C*`, including `Cf`), and unassigned codepoints with U+FFFD, but U+2028 LINE SEPARATOR (`Zl`) and U+2029 PARAGRAPH SEPARATOR (`Zp`) passed through verbatim: `char::is_control` is `Cc`-only and the generated tables in `unicode.rs` only cover the top-level `C` categories. Many terminals and log viewers render U+2028/U+2029 as hard line breaks, so a peer-controlled string (node alias, BOLT 12 description/issuer/ payer_note, `peer_msg`) could inject forged log lines that appear sanitised. Extend `contrib/gen_unicode_general_category.py` to also emit a `Zl`/`Zp` separator predicate and regenerate `unicode.rs` from the same UnicodeData 17.0.0 the existing tables were built from, then filter on the new predicate in `PrintableString`. `Zs` is deliberately excluded as it contains U+0020 SPACE. Add a regression test mirroring the existing bidi-override test. Found by a Loupe scan, tracked by the Bitcoin Security Council: https://github.com/bitcoin-security-council/findings/pull/180 Co-Authored-By: goose (AI agent) Backport of 75defa9cbdb27a2a4408efea60388adad0ed0379 --- contrib/gen_unicode_general_category.py | 35 ++++++++++++++++++++----- lightning-types/src/string.rs | 22 +++++++++++++++- lightning-types/src/unicode.rs | 14 ++++++++++ 3 files changed, 63 insertions(+), 8 deletions(-) diff --git a/contrib/gen_unicode_general_category.py b/contrib/gen_unicode_general_category.py index 4871e967b55..28922e2acb6 100755 --- a/contrib/gen_unicode_general_category.py +++ b/contrib/gen_unicode_general_category.py @@ -10,11 +10,15 @@ """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: +Emits three `pub(crate)` functions taking a `char`, split into disjoint +buckets across the Unicode top-level `C` ("Other") and `Z` ("Separator") +categories so callers can compose them: is_unicode_general_category_other — Cc / Cf / Cs / Co (assigned) + is_unicode_general_category_separator — Zl / Zp (line and paragraph + separators; `Zs` is + excluded as it contains + U+0020 SPACE) is_unicode_general_category_unassigned — Cn (plus codepoints above U+10FFFF, which aren't valid codepoints at all) @@ -126,6 +130,7 @@ def parse_categories(path): ASSIGNED_OTHER_CATS = frozenset({"Cc", "Cf", "Cs", "Co"}) +SEPARATOR_CATS = frozenset({"Zl", "Zp"}) def coalesce_ranges(cats, names, target_cats, *, label): @@ -238,10 +243,11 @@ def _emit_matches_body(lines, arms): lines.append("\t)") -def render_rust(other_ranges, unassigned_ranges): - """Render the final Rust source defining both `char`-taking predicates. +def render_rust(other_ranges, separator_ranges, unassigned_ranges): + """Render the final Rust source defining the `char`-taking predicates. - `other_ranges` and `unassigned_ranges` are lists of `(start, end, label)`. + `other_ranges`, `separator_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 @@ -262,6 +268,19 @@ def render_rust(other_ranges, unassigned_ranges): lines.append("}") lines.append("") + lines.append("/// Returns `true` if `c` is in Unicode general category `Zl` (Line") + lines.append("/// Separator) or `Zp` (Paragraph Separator). Terminals and log viewers") + lines.append("/// commonly render these as hard line breaks, so untrusted strings filter") + lines.append("/// them alongside the `C` buckets. `Zs` (Space Separator) is deliberately") + lines.append("/// excluded: it contains U+0020 SPACE and other ordinary spacing") + lines.append("/// characters.") + lines.append("#[allow(dead_code)]") + lines.append("pub(crate) fn is_unicode_general_category_separator(c: char) -> bool {") + separator_arms = [(_pattern(s, e), label) for s, e, label in separator_ranges] + _emit_matches_body(lines, separator_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") @@ -289,8 +308,9 @@ def main(argv): cats, names = parse_categories(args.unicode_data) other = coalesce_ranges(cats, names, ASSIGNED_OTHER_CATS, label=True) + separator = coalesce_ranges(cats, names, SEPARATOR_CATS, label=True) unassigned = coalesce_ranges(cats, names, frozenset({"Cn"}), label=False) - rust = render_rust(other, unassigned) + rust = render_rust(other, separator, unassigned) if args.output is None: sys.stdout.write(rust) @@ -299,6 +319,7 @@ def main(argv): print( f"Wrote {args.output} " f"({len(other)} assigned-Other ranges, " + f"{len(separator)} separator ranges, " f"{len(unassigned)} unassigned ranges).", file=sys.stderr, ) diff --git a/lightning-types/src/string.rs b/lightning-types/src/string.rs index a21cad411be..a92c5c3e93a 100644 --- a/lightning-types/src/string.rs +++ b/lightning-types/src/string.rs @@ -35,7 +35,14 @@ impl<'a> fmt::Display for PrintableString<'a> { for c in self.0.chars() { 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 { + // U+2028 LINE SEPARATOR and U+2029 PARAGRAPH SEPARATOR (general + // categories `Zl`/`Zp`) are covered by neither `char::is_control` + // (`Cc` only) nor the top-level `C` tables above, but terminals and + // log viewers commonly render them as hard line breaks, allowing an + // attacker-controlled string to inject forged lines into operator + // logs — so the generated separator table filters them as well. + let is_line_separator = is_unicode_general_category_separator(c); + let c = if c.is_control() || is_other || is_unassigned || is_line_separator { core::char::REPLACEMENT_CHARACTER } else { c @@ -78,4 +85,17 @@ mod tests { // 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"); } + + #[test] + fn sanitizes_line_and_paragraph_separators() { + // U+2028 LINE SEPARATOR and U+2029 PARAGRAPH SEPARATOR are general + // categories `Zl`/`Zp`, not `Cc`, so `char::is_control` does not catch + // them, yet terminals and log viewers commonly render them as hard line + // breaks. As with the bidi overrides above, an attacker-controlled string + // must not be able to use them to inject forged log lines. + assert_eq!( + format!("{}", PrintableString("ok\u{2028}forged\u{2029}more")), + "ok\u{FFFD}forged\u{FFFD}more" + ); + } } diff --git a/lightning-types/src/unicode.rs b/lightning-types/src/unicode.rs index 22b21969365..569424cec75 100644 --- a/lightning-types/src/unicode.rs +++ b/lightning-types/src/unicode.rs @@ -50,6 +50,20 @@ pub(crate) fn is_unicode_general_category_other(c: char) -> bool { ) } +/// Returns `true` if `c` is in Unicode general category `Zl` (Line +/// Separator) or `Zp` (Paragraph Separator). Terminals and log viewers +/// commonly render these as hard line breaks, so untrusted strings filter +/// them alongside the `C` buckets. `Zs` (Space Separator) is deliberately +/// excluded: it contains U+0020 SPACE and other ordinary spacing +/// characters. +#[allow(dead_code)] +pub(crate) fn is_unicode_general_category_separator(c: char) -> bool { + matches!( + c as u32, + 0x2028..=0x2029 // SEPARATOR + ) +} + /// 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 From b265232436a6fb279168a2e176d3049e8022c614 Mon Sep 17 00:00:00 2001 From: Matt Corallo Date: Thu, 30 Jul 2026 09:49:04 +0200 Subject: [PATCH 085/135] Avoid panic when reorged claims cannot merge Previously, a deep reorg could resurrect an HTLC package at a height where it was no longer mergeable with its surviving claim. This caused `OnchainTxHandler::blocks_disconnected` to panic on an assertion. When merging fails, we now preserve the resurrected package in `locktimed_packages` so normal block processing registers and broadcasts it as an independent claim. Add a regression test covering the reorg and subsequent broadcast. Thanks to Kyle W. Santiago for reporting this issue. Co-authored-by: Elias Rohrer Backport of f1dc848727639e34cc98b26db32a6059b4bff3aa Silent conflicts resolved in: * lightning/src/ln/reorg_tests.rs --- lightning/src/chain/onchaintx.rs | 20 ++- lightning/src/ln/reorg_tests.rs | 204 +++++++++++++++++++++++++++++++ 2 files changed, 219 insertions(+), 5 deletions(-) diff --git a/lightning/src/chain/onchaintx.rs b/lightning/src/chain/onchaintx.rs index fb65aa0f157..6b53e694ebc 100644 --- a/lightning/src/chain/onchaintx.rs +++ b/lightning/src/chain/onchaintx.rs @@ -1163,14 +1163,24 @@ impl OnchainTxHandler { if let Some(pending_claim) = self.claimable_outpoints.get(package.outpoints()[0]) { if let Some(request) = self.pending_claim_requests.get_mut(&pending_claim.0) { - assert!(request.merge_package(package, new_best_height + 1).is_ok()); - // Using a HashMap guarantee us than if we have multiple outpoints getting - // resurrected only one bump claim tx is going to be broadcast - bump_candidates.insert(pending_claim.clone(), request.clone()); + if request.can_merge_with(&package, new_best_height + 1) { + assert!(request.merge_package(package, new_best_height + 1).is_ok()); + // Using a HashMap guarantee us than if we have multiple outpoints getting + // resurrected only one bump claim tx is going to be broadcast + bump_candidates.insert(pending_claim.clone(), request.clone()); + continue; + } } } + // While we really should push the new package through the generation below + // (as it is not, in fact, locktimed to a later height), to avoid additional + // complexity we shove the package in the locktimed set instead, which will + // result in it getting handled when the reorg processing finishes and the + // new tip is connected. + let package_locktime = package.package_locktime(0); + self.locktimed_packages.entry(package_locktime).or_default().push(package); }, - _ => {}, + OnchainEvent::Claim { .. } => {}, } } else { self.onchain_events_awaiting_threshold_conf.push(entry); diff --git a/lightning/src/ln/reorg_tests.rs b/lightning/src/ln/reorg_tests.rs index ede6acfb639..32041905139 100644 --- a/lightning/src/ln/reorg_tests.rs +++ b/lightning/src/ln/reorg_tests.rs @@ -1230,3 +1230,207 @@ fn test_split_htlc_expiry_tracking() { do_test_split_htlc_expiry_tracking(true, false, true); do_test_split_htlc_expiry_tracking(false, false, true); } + +fn do_test_reorg_resurrect_split_htlc_package_with_future_locktime(style: ConnectStyle) { + // Exercises a failed `request.merge_package(..)` in `OnchainTxHandler::blocks_disconnected`. + // + // Two of our outbound HTLCs land on the counterparty's commitment with `cltv_expiry` two + // blocks apart. If the commitment confirms at a height at or past the *later* expiry, both + // timeout claims are immediately spendable, so their `package_locktime` agree and they + // aggregate into a single claim. The counterparty then claims only the earlier-expiring HTLC + // with its preimage, which splits that input back out into a `ContentiousOutpoint`. On a reorg + // back below the later expiry, the split-off package's locktime requirement has been met but + // the surviving one's has not, so the two are no longer mergeable and the assert's condition + // is false. + let chanmon_cfgs = create_chanmon_cfgs(2); + let node_cfgs = create_node_cfgs(2, &chanmon_cfgs); + // A legacy (non-anchor) channel is required: with anchors the counterparty aggregates both of + // its HTLC-success claims into one transaction, which would spend *every* outpoint in our + // claim and take the `is_claim_subset_of_tx` path instead of splitting. + let legacy_cfg = test_default_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); + *nodes[0].connect_style.borrow_mut() = 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_id, funding_tx) = + create_announced_chan_between_nodes_with_value(&nodes, 0, 1, 10_000_000, 0); + + // Two outbound HTLCs with different amounts (so we can tell their commitment outputs apart) + // and `cltv_expiry` exactly two blocks apart. + let amt_a_msat = 100_000_000; + let amt_b_msat = 200_000_000; + let (preimage_a, payment_hash_a, ..) = route_payment(&nodes[0], &[&nodes[1]], amt_a_msat); + connect_blocks(&nodes[0], 2); + connect_blocks(&nodes[1], 2); + let (preimage_b, payment_hash_b, ..) = route_payment(&nodes[0], &[&nodes[1]], amt_b_msat); + + let (cltv_a, cltv_b) = { + let chans = nodes[0].node.list_channels(); + assert_eq!(chans.len(), 1); + let mut htlcs = chans[0].pending_outbound_htlcs.clone(); + assert_eq!(htlcs.len(), 2, "{htlcs:?}"); + htlcs.sort_by_key(|h| h.cltv_expiry); + assert_eq!(htlcs[0].amount_msat, amt_a_msat); + assert_eq!(htlcs[1].amount_msat, amt_b_msat); + (htlcs[0].cltv_expiry, htlcs[1].cltv_expiry) + }; + println!("[repro] A height after routing = {}", nodes[0].best_block_info().1); + println!("[repro] cltv_a = {cltv_a}, cltv_b = {cltv_b}"); + assert_eq!(cltv_b, cltv_a + 2, "need the expiries exactly 2 apart"); + + nodes[0].node.peer_disconnected(node_id_1); + nodes[1].node.peer_disconnected(node_id_0); + + // Give node B both preimages so it will claim both HTLCs on-chain. + nodes[1].node.claim_funds(preimage_a); + expect_payment_claimed!(nodes[1], payment_hash_a, amt_a_msat); + nodes[1].node.claim_funds(preimage_b); + expect_payment_claimed!(nodes[1], payment_hash_b, amt_b_msat); + check_added_monitors(&nodes[1], 2); + + // Node B force-closes, giving us its commitment transaction. + let err = "Channel force-closed".to_string(); + 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, false, &[node_id_0], 10_000_000); + + // On a legacy channel node B knows both preimages, so it broadcasts its commitment together + // with a separate, pre-signed HTLC-success transaction per HTLC. + let mut bs_txn = nodes[1].tx_broadcaster.txn_broadcast(); + println!("[repro] node B broadcast {} txn on force-close", bs_txn.len()); + assert_eq!(bs_txn.len(), 3, "{bs_txn:?}"); + let commitment_tx = bs_txn.remove(0); + check_spends!(commitment_tx, funding_tx); + let commitment_txid = commitment_tx.compute_txid(); + for tx in bs_txn.iter() { + check_spends!(tx, commitment_tx); + assert_eq!(tx.input.len(), 1); + } + // Take only the HTLC-success transaction for the *earlier*-expiring HTLC, identified by the + // value of the commitment output it spends. + let partial_claim_tx = bs_txn + .into_iter() + .find(|tx| { + tx.input.len() == 1 + && tx.input[0].previous_output.txid == commitment_txid + && commitment_tx.output[tx.input[0].previous_output.vout as usize].value.to_sat() + == amt_a_msat / 1000 + }) + .expect("node B should have a single-input HTLC-success tx for the earlier HTLC"); + println!( + "[repro] node B partial claim spends {}:{} (value {} sat)", + partial_claim_tx.input[0].previous_output.txid, + partial_claim_tx.input[0].previous_output.vout, + commitment_tx.output[partial_claim_tx.input[0].previous_output.vout as usize] + .value + .to_sat(), + ); + + // Advance node A so that the block containing the commitment lands at exactly `cltv_b`. Note + // this stays below `cltv_a + LATENCY_GRACE_PERIOD_BLOCKS`, so node A does not force-close on + // its own first. + let a_height = nodes[0].best_block_info().1; + assert!(a_height < cltv_b - 1, "a_height {a_height} cltv_b {cltv_b}"); + connect_blocks(&nodes[0], cltv_b - 1 - a_height); + assert_eq!(nodes[0].best_block_info().1, cltv_b - 1); + assert_eq!(nodes[0].tx_broadcaster.txn_broadcast().len(), 0); + + mine_transaction(&nodes[0], &commitment_tx); + let commitment_conf_height = nodes[0].best_block_info().1; + println!("[repro] commitment confirmed on A at height {commitment_conf_height}"); + assert_eq!(commitment_conf_height, cltv_b); + check_closed_broadcast(&nodes[0], 1, false); + let reason = ClosureReason::CommitmentTxConfirmed; + check_closed_event(&nodes[0], 1, reason, false, &[node_id_1], 10_000_000); + check_added_monitors(&nodes[0], 1); + + // Both timeout claims must have aggregated into a single transaction, otherwise the path we + // are trying to reach does not exist. + { + let txn = nodes[0].tx_broadcaster.txn_broadcast(); + println!("[repro] node A broadcast {} txn when the commitment confirmed:", txn.len()); + for tx in txn.iter() { + println!( + "[repro] txid {} locktime {} inputs {:?}", + tx.compute_txid(), + tx.lock_time.to_consensus_u32(), + tx.input.iter().map(|i| i.previous_output).collect::>(), + ); + } + let aggregated = txn + .iter() + .find(|tx| { + tx.input.len() == 2 + && tx.input.iter().all(|i| i.previous_output.txid == commitment_txid) + }) + .expect("node A must aggregate both HTLC timeout claims into one transaction"); + assert_eq!(aggregated.lock_time.to_consensus_u32(), cltv_b); + } + + // Now confirm the counterparty's preimage claim of the earlier HTLC, splitting it back out of + // our aggregated claim. + mine_transaction(&nodes[0], &partial_claim_tx); + let spend_conf_height = nodes[0].best_block_info().1; + println!("[repro] node B's partial claim confirmed on A at height {spend_conf_height}"); + { + let txn = nodes[0].tx_broadcaster.txn_broadcast(); + println!("[repro] node A broadcast {} txn after the split:", txn.len()); + for tx in txn.iter() { + println!( + "[repro] txid {} locktime {} inputs {:?}", + tx.compute_txid(), + tx.lock_time.to_consensus_u32(), + tx.input.iter().map(|i| i.previous_output).collect::>(), + ); + } + } + + // Node A learned both preimages from node B's on-chain claim, so drain the resulting payment + // events; we want the only possible failure below to be the one we are testing for. + nodes[0].node.get_and_clear_pending_events(); + check_added_monitors(&nodes[0], 1); + + // Reorg back to `cltv_b - 2`, which is `cltv_a`. The resurrected package (locktime `cltv_a`) + // is spendable, but the surviving request still requires locktime `cltv_b`. + let disconnect = spend_conf_height - cltv_b + 2; + let new_best_height = spend_conf_height - disconnect; + println!( + "[repro] disconnecting {disconnect} blocks: new_best_height will be {new_best_height} \ + (cltv_a {cltv_a}, cltv_b {cltv_b}); re-merge is evaluated at {}", + new_best_height + 1, + ); + assert!(cltv_a <= new_best_height); + assert!(new_best_height + 1 < cltv_b); + disconnect_blocks(&nodes[0], disconnect); + println!("[repro] survived the disconnect, height now {}", nodes[0].best_block_info().1); + + // The failed merge must not discard the resurrected package. On the next block it should be + // registered as an independent claim and broadcast on its own. + connect_blocks(&nodes[0], 1); + let early_htlc_outpoint = partial_claim_tx.input[0].previous_output; + let txn = nodes[0].tx_broadcaster.txn_broadcast(); + let independent_claim = txn + .iter() + .find(|tx| tx.input.len() == 1 && tx.input[0].previous_output == early_htlc_outpoint) + .expect("the resurrected HTLC package must be broadcast as an independent claim"); + assert_eq!(independent_claim.lock_time.to_consensus_u32(), new_best_height + 1); +} + +#[test] +fn test_reorg_resurrect_split_htlc_package_with_future_locktime() { + // `FullBlockDisconnectionsSkippingViaListen` issues a single `blocks_disconnected` call with + // the fork point, which is what `lightning-block-sync` does after detecting a reorg. The + // styles which instead walk the chain backwards one block at a time re-merge successfully on + // the first (shallowest) disconnection, and the `*ReorgsOnlyTip` styles go through + // `transaction_unconfirmed`, which disconnects only to `entry.height - 1`; neither reaches the + // case below. + do_test_reorg_resurrect_split_htlc_package_with_future_locktime( + ConnectStyle::FullBlockDisconnectionsSkippingViaListen, + ); +} From d430b49c13fba8a724c7b290f211df6512cc4585 Mon Sep 17 00:00:00 2001 From: Matt Corallo Date: Tue, 4 Aug 2026 14:36:14 +0000 Subject: [PATCH 086/135] Reject attempts to advance one-hop blinded forward paths A malicious reply path can contain a single decryptable forward hop. Advancing that path removed its only hop, causing onion construction to panic when a handler attempted to respond. Reject paths with fewer than two hops before attempting to advance them. The regression test exercises the untrusted response path and verifies that it returns an error without panicking. Reported by Project Loupe. Test written by: Elias Rohrer , which was Co-Authored-By: HAL 9000 Backport of 969a40cf902b51d56e153d58ce1af9e79df4397f Conflicts resolved in: * lightning/src/blinded_path/payment.rs * lightning/src/onion_message/functional_tests.rs --- lightning/src/blinded_path/message.rs | 4 +++ lightning/src/blinded_path/payment.rs | 5 +++ lightning/src/ln/outbound_payment.rs | 9 +++++- .../src/onion_message/functional_tests.rs | 32 +++++++++++++++++++ 4 files changed, 49 insertions(+), 1 deletion(-) diff --git a/lightning/src/blinded_path/message.rs b/lightning/src/blinded_path/message.rs index a2d92fdb7d9..fe215332c9c 100644 --- a/lightning/src/blinded_path/message.rs +++ b/lightning/src/blinded_path/message.rs @@ -192,6 +192,10 @@ impl BlindedMessagePath { NL::Target: NodeIdLookUp, T: secp256k1::Signing + secp256k1::Verification, { + if self.0.blinded_hops.len() <= 1 { + // The resulting blinded path has to always be left with at least one hop. + return Err(()); + } let control_tlvs_ss = node_signer.ecdh(Recipient::Node, &self.0.blinding_point, None)?; let rho = onion_utils::gen_rho_from_shared_secret(&control_tlvs_ss.secret_bytes()); let encrypted_control_tlvs = &self.0.blinded_hops.get(0).ok_or(())?.encrypted_payload; diff --git a/lightning/src/blinded_path/payment.rs b/lightning/src/blinded_path/payment.rs index b38d73ae699..d8059b03c89 100644 --- a/lightning/src/blinded_path/payment.rs +++ b/lightning/src/blinded_path/payment.rs @@ -193,6 +193,11 @@ impl BlindedPaymentPath { NL::Target: NodeIdLookUp, T: secp256k1::Signing + secp256k1::Verification, { + if self.inner_path.blinded_hops.len() <= 1 { + // The resulting blinded path has to always be left with at least one hop. + return Err(()); + } + match self.decrypt_intro_payload::(node_signer) { Ok(( BlindedPaymentTlvs::Forward(ForwardTlvs { short_channel_id, .. }), diff --git a/lightning/src/ln/outbound_payment.rs b/lightning/src/ln/outbound_payment.rs index 670d3a18ba9..975f5fb76fe 100644 --- a/lightning/src/ln/outbound_payment.rs +++ b/lightning/src/ln/outbound_payment.rs @@ -1049,7 +1049,14 @@ impl OutboundPayments { }, }; if introduction_node_id == our_node_id { - let _ = path.advance_path_by_one(node_signer, node_id_lookup, secp_ctx); + // TODO: Switch this to + // Bolt12PaymentError::SendingFailed(RetryableSendFailure::UnpayableInstructions) + // once we add it. + if let Err(()) = path.advance_path_by_one(node_signer, node_id_lookup, secp_ctx) { + let reason = PaymentFailureReason::RouteNotFound; + self.abandon_payment(payment_id, reason, pending_events); + Err(Bolt12PaymentError::SendingFailed(RetryableSendFailure::RouteNotFound))? + } } } } diff --git a/lightning/src/onion_message/functional_tests.rs b/lightning/src/onion_message/functional_tests.rs index 605a81a4f95..128c30a9473 100644 --- a/lightning/src/onion_message/functional_tests.rs +++ b/lightning/src/onion_message/functional_tests.rs @@ -784,6 +784,38 @@ fn we_are_intro_node() { pass_along_path(&nodes); } +#[test] +fn malformed_one_hop_reply_path_does_not_panic_when_we_are_intro_node() { + // A reply path may be provided by an untrusted sender. If its only hop is a forward hop for us, + // advancing it must reject the path instead of leaving a zero-hop destination behind. + let nodes = create_nodes(2); + let secp_ctx = Secp256k1::new(); + let intermediate_nodes = + [MessageForwardNode { node_id: nodes[0].node_id, short_channel_id: None }]; + let valid_reply_path = BlindedMessagePath::new( + &intermediate_nodes, + nodes[1].node_id, + nodes[1].messenger.node_signer.get_receive_auth_key(), + MessageContext::Custom(Vec::new()), + &*nodes[1].entropy_source, + &secp_ctx, + ); + let malformed_reply_path = BlindedMessagePath::from_blinded_path( + nodes[0].node_id, + valid_reply_path.blinding_point(), + vec![valid_reply_path.blinded_hops()[0].clone()], + ); + + let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + nodes[0].messenger.handle_onion_message_response( + TestCustomMessage::Pong, + Responder::new(malformed_reply_path).respond(), + ) + })); + assert!(result.is_ok(), "responding over a malformed reply path must not panic"); + assert_eq!(result.unwrap(), Err(SendError::BlindedPathAdvanceFailed)); +} + #[test] fn invalid_blinded_path_error() { // Make sure we error as expected if a provided blinded path has 0 hops. From 61e47176a569a4591877d2fb6b18db8ed1a56fcc Mon Sep 17 00:00:00 2001 From: elnosh Date: Thu, 5 Feb 2026 17:48:47 -0500 Subject: [PATCH 087/135] Fix off-by-one for unfunded channel peers When accepting channels manually, it would fail if the # of peers without funded channels was == `MAX_UNFUNDED_CHANNEL_PEERS`, however, it should fail if the # of peers > `MAX_UNFUNDED_CHANNEL_PEERS`. Backport of b6bd3866fb3824f48b85f7875541f9027c511b44 --- lightning/src/ln/channel_open_tests.rs | 2 +- lightning/src/ln/channelmanager.rs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/lightning/src/ln/channel_open_tests.rs b/lightning/src/ln/channel_open_tests.rs index 483cc5d793b..2adeb905d7c 100644 --- a/lightning/src/ln/channel_open_tests.rs +++ b/lightning/src/ln/channel_open_tests.rs @@ -110,7 +110,7 @@ fn test_0conf_limiting() { }; // First, get us up to MAX_UNFUNDED_CHANNEL_PEERS so we can test at the edge - for _ in 0..MAX_UNFUNDED_CHANNEL_PEERS - 1 { + for _ in 0..MAX_UNFUNDED_CHANNEL_PEERS { let random_pk = PublicKey::from_secret_key( &nodes[0].node.secp_ctx, &SecretKey::from_slice(&nodes[1].keys_manager.get_secure_random_bytes()).unwrap(), diff --git a/lightning/src/ln/channelmanager.rs b/lightning/src/ln/channelmanager.rs index d7a73cef4ca..28a19d4eadb 100644 --- a/lightning/src/ln/channelmanager.rs +++ b/lightning/src/ln/channelmanager.rs @@ -10015,7 +10015,7 @@ This indicates a bug inside LDK. Please report this error at https://github.com/ // If this peer already has some channels, a new channel won't increase our number of peers // with unfunded channels, so as long as we aren't over the maximum number of unfunded // channels per-peer we can accept channels from a peer with existing ones. - if is_only_peer_channel && peers_without_funded_channels >= MAX_UNFUNDED_CHANNEL_PEERS { + if is_only_peer_channel && peers_without_funded_channels > MAX_UNFUNDED_CHANNEL_PEERS { let send_msg_err_event = MessageSendEvent::HandleError { node_id: channel.context().get_counterparty_node_id(), action: msgs::ErrorAction::SendErrorMessage{ From ae433afd198fb774e9b5e6db471b4d2d5a82d253 Mon Sep 17 00:00:00 2001 From: Matt Corallo Date: Tue, 4 Aug 2026 17:41:03 +0000 Subject: [PATCH 088/135] Apply the unfunded channel peer limit to all unaccepted channels If a peer floods us with channel requests, we previously failed to enforce `MAX_UNFUNDED_CHANNEL_PEERS` as we considered the peer to have some channels once it has more than one request pending. Instead, we have to make sure we reject such channels by looking at pending channels as well. Co-Authored-By: Claude Backport of 56a4ee43f682a81626a85a893e9268d42fbbbb27 Conflicts resolved in: * lightning/src/ln/channelmanager.rs * lightning/src/ln/channel_open_tests.rs --- lightning/src/ln/channel_open_tests.rs | 88 ++++++++++++++++++++++++++ lightning/src/ln/channelmanager.rs | 17 +++-- 2 files changed, 99 insertions(+), 6 deletions(-) diff --git a/lightning/src/ln/channel_open_tests.rs b/lightning/src/ln/channel_open_tests.rs index 2adeb905d7c..0292728535c 100644 --- a/lightning/src/ln/channel_open_tests.rs +++ b/lightning/src/ln/channel_open_tests.rs @@ -183,6 +183,94 @@ fn test_0conf_limiting() { get_event_msg!(nodes[1], MessageSendEvent::SendAcceptChannel, last_random_pk); } +#[test] +fn test_unfunded_channel_peer_limit_multiple_requests() { + // Tests that a peer cannot bypass the `MAX_UNFUNDED_CHANNEL_PEERS` limit by sending us several + // `open_channel` messages in quick succession, before we get a chance to accept any of them. + let chanmon_cfgs = create_chanmon_cfgs(2); + let node_cfgs = create_node_cfgs(2, &chanmon_cfgs); + let mut settings = test_default_channel_config(); + settings.manually_accept_inbound_channels = true; + let node_chanmgrs = create_node_chanmgrs(2, &node_cfgs, &[None, Some(settings)]); + let nodes = create_network(2, &node_cfgs, &node_chanmgrs); + + // Note that create_network connects the nodes together for us + let node_b = nodes[1].node.get_our_node_id(); + nodes[0].node.create_channel(node_b, 100_000, 0, 42, None, None).unwrap(); + let mut open_channel_msg = get_event_msg!(nodes[0], MessageSendEvent::SendOpenChannel, node_b); + let init_msg = &msgs::Init { + features: nodes[0].node.init_features(), + networks: None, + remote_network_address: None, + }; + + // First, get us up to MAX_UNFUNDED_CHANNEL_PEERS so we can test at the edge + for _ in 0..MAX_UNFUNDED_CHANNEL_PEERS { + let random_pk = PublicKey::from_secret_key( + &nodes[0].node.secp_ctx, + &SecretKey::from_slice(&nodes[1].keys_manager.get_secure_random_bytes()).unwrap(), + ); + nodes[1].node.peer_connected(random_pk, init_msg, true).unwrap(); + + nodes[1].node.handle_open_channel(random_pk, &open_channel_msg); + let events = nodes[1].node.get_and_clear_pending_events(); + match events[0] { + Event::OpenChannelRequest { temporary_channel_id, .. } => { + nodes[1] + .node + .accept_inbound_channel(&temporary_channel_id, &random_pk, 23, None) + .unwrap(); + }, + _ => panic!("Unexpected event"), + } + get_event_msg!(nodes[1], MessageSendEvent::SendAcceptChannel, random_pk); + open_channel_msg.common_fields.temporary_channel_id = + ChannelId::temporary_from_entropy_source(&nodes[0].keys_manager); + } + + // Now have one more peer request two channels before we accept either of them. + let last_random_pk = PublicKey::from_secret_key( + &nodes[0].node.secp_ctx, + &SecretKey::from_slice(&nodes[1].keys_manager.get_secure_random_bytes()).unwrap(), + ); + nodes[1].node.peer_connected(last_random_pk, init_msg, true).unwrap(); + + for _ in 0..2 { + nodes[1].node.handle_open_channel(last_random_pk, &open_channel_msg); + open_channel_msg.common_fields.temporary_channel_id = + ChannelId::temporary_from_entropy_source(&nodes[0].keys_manager); + } + + let events = nodes[1].node.get_and_clear_pending_events(); + assert_eq!(events.len(), 2); + + // Neither of them should be acceptable, as the peer still has no funded channel with us and + // we're already at the limit of peers with unfunded channels. + for event in events { + match event { + Event::OpenChannelRequest { temporary_channel_id, .. } => { + match nodes[1].node.accept_inbound_channel( + &temporary_channel_id, + &last_random_pk, + 23, + None, + ) { + Err(APIError::APIMisuseError { err }) => assert_eq!( + err, + "Too many peers with unfunded channels, refusing to accept new ones" + ), + _ => panic!(), + } + assert_eq!( + get_err_msg(&nodes[1], &last_random_pk).channel_id, + temporary_channel_id + ); + }, + _ => panic!("Unexpected event"), + } + } +} + #[test] fn test_inbound_anchors_manual_acceptance() { let mut anchors_cfg = test_default_channel_config(); diff --git a/lightning/src/ln/channelmanager.rs b/lightning/src/ln/channelmanager.rs index 28a19d4eadb..dbfe949dd25 100644 --- a/lightning/src/ln/channelmanager.rs +++ b/lightning/src/ln/channelmanager.rs @@ -3146,7 +3146,8 @@ pub(crate) const ENABLE_GOSSIP_TICKS: u8 = 5; pub(super) const MAX_UNFUNDED_CHANS_PER_PEER: usize = 4; /// The maximum number of peers from which we will allow pending unfunded channels. Once we reach -/// this many peers we reject new (inbound) channels from peers with which we don't have a channel. +/// this many peers we reject new (inbound) channels from peers with which we don't have a funded +/// channel. pub(super) const MAX_UNFUNDED_CHANNEL_PEERS: usize = 50; /// The maximum allowed size for peer storage, in bytes. @@ -9922,7 +9923,9 @@ 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; - let is_only_peer_channel = peer_state.total_channel_count() == 1; + let peer_lacks_funded_channels = + Self::unfunded_channel_count(peer_state, self.best_block.read().unwrap().height) + == peer_state.total_channel_count(); // Find (and remove) the channel in the unaccepted table. If it's not there, something weird is // happening and return an error. N.B. that we create channel with an outbound SCID of zero so @@ -10012,10 +10015,12 @@ This indicates a bug inside LDK. Please report this error at https://github.com/ return Err(APIError::APIMisuseError { err: err_str }); } else { - // If this peer already has some channels, a new channel won't increase our number of peers - // with unfunded channels, so as long as we aren't over the maximum number of unfunded - // channels per-peer we can accept channels from a peer with existing ones. - if is_only_peer_channel && peers_without_funded_channels > MAX_UNFUNDED_CHANNEL_PEERS { + // If this peer already has a funded channel with us, accepting another channel won't + // increase the number of unfunded channels. Otherwise, make sure we don't end up with + // too many peers with unfunded channels afterwards. + if peer_lacks_funded_channels + && peers_without_funded_channels > MAX_UNFUNDED_CHANNEL_PEERS + { let send_msg_err_event = MessageSendEvent::HandleError { node_id: channel.context().get_counterparty_node_id(), action: msgs::ErrorAction::SendErrorMessage{ From 3853314bb851005b2baea0d02a6292178b19ab32 Mon Sep 17 00:00:00 2001 From: Wilmer Paulino Date: Fri, 31 Jul 2026 11:15:47 -0700 Subject: [PATCH 089/135] Account for message type prefix len in prevtx length enforcement Lightning wire messages have a maximum size of 65535 bytes, which already accounts for the required two-byte message type prefix. Our `TxAddInput::prevtx` length enforcement relied solely on the `serialized_length` of the message, which does not account for the prefix. Backport of d29e141573d72b0bf98eaf8ce484ca7daad656e9 The offending code is in a different location on 0.2, and thus this patch was largely rewritten, conflicts in: * lightning/src/ln/funding.rs * lightning/src/ln/channel.rs --- lightning/src/ln/channel.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lightning/src/ln/channel.rs b/lightning/src/ln/channel.rs index 78dd7adf23a..a89e240edc1 100644 --- a/lightning/src/ln/channel.rs +++ b/lightning/src/ln/channel.rs @@ -12009,7 +12009,7 @@ where // Mutually exclusive with prevtx, which is accounted for below. shared_input_txid: None, }; - let message_len = MESSAGE_TEMPLATE.serialized_length() + prevtx.serialized_length(); + let message_len = 2 + MESSAGE_TEMPLATE.serialized_length() + prevtx.serialized_length(); if message_len > LN_MAX_MSG_LEN { return Err(APIError::APIMisuseError { err: format!( From 4044a54d4384b72bb22715575b4f36d34e7fa076 Mon Sep 17 00:00:00 2001 From: Matt Corallo Date: Thu, 30 Jul 2026 11:05:23 +0200 Subject: [PATCH 090/135] Avoid oversized relayed failure messages A downstream peer could send a maximum-sized update_fail_htlc without attribution data. Adding attribution data while relaying the failure made the message exceed the Noise framing limit and panic during encryption. Drop the attribution data again when adding it would push the relayed failure over the wire limit, while preserving attribution for packets that fit. The message length is derived from serialized_length on the message and on the attribution data itself rather than from hardcoded field sizes, which also lets us replace a stale assertion on the maximum failure data length. Add a regression test for relaying a maximum-sized failure and correct the existing size test to include the message type. Reported by Project Loupe. Co-Authored-By: Elias Rohrer Co-Authored-By: HAL 9000 Backport of 6b1dfb1a4e37ab4942abd8d21137eb88ee38ac6c Trivial conflicts resolved in: * lightning/src/ln/onion_utils.rs --- lightning/src/ln/onion_utils.rs | 83 +++++++++++++++++++++++++-------- 1 file changed, 64 insertions(+), 19 deletions(-) diff --git a/lightning/src/ln/onion_utils.rs b/lightning/src/ln/onion_utils.rs index 735ac4012ab..029b7cf5952 100644 --- a/lightning/src/ln/onion_utils.rs +++ b/lightning/src/ln/onion_utils.rs @@ -17,6 +17,9 @@ use crate::events::HTLCHandlingFailureReason; use crate::ln::channel::TOTAL_BITCOIN_SUPPLY_SATOSHIS; use crate::ln::channelmanager::{HTLCSource, RecipientOnionFields}; use crate::ln::msgs::{self, DecodeError}; +use crate::ln::types::ChannelId; +use crate::ln::wire::Encode; +use crate::ln::LN_MAX_MSG_LEN; use crate::offers::invoice_request::InvoiceRequest; use crate::routing::gossip::NetworkUpdate; use crate::routing::router::{BlindedTail, Path, RouteHop, RouteParameters, TrampolineHop}; @@ -26,7 +29,7 @@ use crate::types::payment::{PaymentHash, PaymentPreimage}; use crate::util::errors::APIError; use crate::util::logger::Logger; use crate::util::ser::{ - LengthCalculatingWriter, Readable, ReadableArgs, VecWriter, Writeable, Writer, + BigSize, LengthCalculatingWriter, Readable, ReadableArgs, VecWriter, Writeable, Writer, }; use bitcoin::hashes::cmp::fixed_time_eq; @@ -896,7 +899,6 @@ fn build_unencrypted_failure_packet( hold_time: u32, min_packet_len: usize, ) -> OnionErrorPacket { assert_eq!(shared_secret.len(), 32); - assert!(failure_data.len() <= 64531); // Failure len is 2 bytes type plus the data. let failure_len = 2 + failure_data.len(); @@ -932,6 +934,9 @@ fn build_unencrypted_failure_packet( let mut packet = OnionErrorPacket { data: writer.0, attribution_data: None }; update_attribution_data(&mut packet, shared_secret, hold_time); + // The failure packets we build ourselves must always be small enough to send to our peer. + debug_assert!(update_fail_htlc_wire_len(&packet) <= LN_MAX_MSG_LEN); + packet } @@ -2722,6 +2727,31 @@ pub(crate) const HMAC_LEN: usize = 4; // subsequent node, the number of HMACs decreases by 1. 20 + 19 + 18 + ... + 1 = 20 * 21 / 2 = 210. pub(crate) const HMAC_COUNT: usize = MAX_HOPS * (MAX_HOPS + 1) / 2; +fn update_fail_htlc_wire_len(onion_error: &OnionErrorPacket) -> usize { + let empty_msg_len = msgs::UpdateFailHTLC { + channel_id: ChannelId([0; 32]), + htlc_id: 0, + reason: Vec::new(), + attribution_data: None, + } + .serialized_length(); + + let attribution_data_len = + onion_error.attribution_data.as_ref().map_or(0, |attribution_data| { + let value_len = attribution_data.serialized_length(); + // Attribution data is written as a TLV of type 1 + BigSize(1).serialized_length() + + BigSize(value_len as u64).serialized_length() + + value_len + }); + + // Messages are prefixed with their two-byte type on the wire. + msgs::UpdateFailHTLC::TYPE.serialized_length() + + empty_msg_len + + onion_error.data.len() + + attribution_data_len +} + #[derive(Clone, Debug, Hash, PartialEq, Eq)] /// Attribution data allows the sender of an HTLC to identify which hop failed an HTLC robustly, /// preventing earlier hops from corrupting the HTLC failure information (or at least allowing the @@ -2907,6 +2937,12 @@ fn process_failure_packet( // Add this node's attribution data. update_attribution_data(onion_error, shared_secret, hold_time); + + // A downstream peer could send us a maximum-sized failure packet without attribution data, in + // which case adding ours would push the `update_fail_htlc` over the message size limit. + if update_fail_htlc_wire_len(onion_error) > LN_MAX_MSG_LEN { + onion_error.attribution_data = None; + } } /// Updates fulfill attribution data with the given hold time for an intermediate or final node. If no downstream @@ -3980,29 +4016,20 @@ mod tests { #[test] fn test_failure_packet_max_size() { - // Create a failure message of the maximum size of 65535 bytes. It is composed of: - // - 32 bytes channel id - // - 8 bytes htlc id - // - 2 bytes reason length - // - 32 bytes of hmac - // - 2 bytes of failure type - // - 2 bytes of failure length - // - 64531 bytes of failure data - // - 2 bytes of pad len (0) - // - 1 byte attribution data tlv type - // - 3 bytes attribution data tlv length - // - 80 bytes of attribution data hold times - // - 840 bytes of attribution data hmacs - let failure_data = vec![0; 64531]; - let shared_secret = [0; 32]; + let reason = LocalHTLCFailureReason::TemporaryNodeFailure; + + let empty = super::build_unencrypted_failure_packet(&shared_secret, reason, &[], 0, 0); + let failure_data = vec![0; LN_MAX_MSG_LEN - update_fail_htlc_wire_len(&empty)]; + let onion_error = super::build_unencrypted_failure_packet( &shared_secret, - LocalHTLCFailureReason::TemporaryNodeFailure, + reason, &failure_data, 0, DEFAULT_MIN_FAILURE_PACKET_LEN, ); + assert!(onion_error.attribution_data.is_some()); let msg = UpdateFailHTLC { channel_id: ChannelId([0; 32]), @@ -4012,8 +4039,26 @@ mod tests { }; let mut buffer = Vec::new(); + msgs::UpdateFailHTLC::TYPE.write(&mut buffer).unwrap(); msg.write(&mut buffer).unwrap(); + assert_eq!(buffer.len(), LN_MAX_MSG_LEN); + assert_eq!(update_fail_htlc_wire_len(&msg.into()), buffer.len()); + } + + #[test] + fn relaying_max_sized_failure_packet_does_not_exceed_wire_limit() { + let empty = OnionErrorPacket { data: Vec::new(), attribution_data: None }; + let msg = UpdateFailHTLC { + channel_id: ChannelId([0; 32]), + htlc_id: 0, + reason: vec![0; LN_MAX_MSG_LEN - update_fail_htlc_wire_len(&empty)], + attribution_data: None, + }; + + let onion_error = + HTLCFailReason::from_msg(&msg).get_encrypted_failure_packet(&[1; 32], &None); + assert!(onion_error.attribution_data.is_none()); - assert_eq!(buffer.len(), 65535); + assert_eq!(update_fail_htlc_wire_len(&onion_error), LN_MAX_MSG_LEN); } } From ecc842adf1e3b2efb1bf598c1ac68fcc87661f74 Mon Sep 17 00:00:00 2001 From: Matt Corallo Date: Tue, 4 Aug 2026 14:18:32 +0000 Subject: [PATCH 091/135] Return `Err`s` instead of panicking on oversized messages While this code should remain unreachable as it likely indicates we're going to end up force-closing a channel due to being unable to communicate with a peer, we shouldn't bring down the whole process for it if we can avoid it. Instead, at least return an `Err` so we can figure out what to do with it in `PeerManager`. Co-Authored-By: Claude Backport of 08f12bc7ad542943e9eca88bffacb0f7ec4e75ca Conflicts resolved in: * lightning/src/ln/peer_channel_encryptor.rs * lightning/src/ln/peer_handler.rs --- fuzz/src/peer_crypt.rs | 5 +- lightning/src/ln/peer_channel_encryptor.rs | 77 ++++++++++++++++------ lightning/src/ln/peer_handler.rs | 19 +++--- 3 files changed, 69 insertions(+), 32 deletions(-) diff --git a/fuzz/src/peer_crypt.rs b/fuzz/src/peer_crypt.rs index b01aa02400b..afd6ccef307 100644 --- a/fuzz/src/peer_crypt.rs +++ b/fuzz/src/peer_crypt.rs @@ -79,9 +79,8 @@ pub fn do_test(data: &[u8]) { let mut buf = [0; 65536 + 16]; loop { if get_slice!(1)[0] == 0 { - crypter.encrypt_buffer(MessageBuf::from_encoded(&get_slice!(slice_to_be16( - get_slice!(2) - )))); + let msg = MessageBuf::from_encoded(&get_slice!(slice_to_be16(get_slice!(2)))).unwrap(); + crypter.encrypt_buffer(msg); } else { let len = match crypter.decrypt_length_header(get_slice!(16 + 2)) { Ok(len) => len, diff --git a/lightning/src/ln/peer_channel_encryptor.rs b/lightning/src/ln/peer_channel_encryptor.rs index 09b970a9ab2..a0945c06ea3 100644 --- a/lightning/src/ln/peer_channel_encryptor.rs +++ b/lightning/src/ln/peer_channel_encryptor.rs @@ -10,7 +10,7 @@ use crate::prelude::*; use crate::ln::msgs; -use crate::ln::msgs::LightningError; +use crate::ln::msgs::{ErrorAction, LightningError}; use crate::ln::wire; use crate::sign::{NodeSigner, Recipient}; @@ -524,10 +524,11 @@ impl PeerChannelEncryptor { /// /// For effeciency, the [`Vec::capacity`] should be at least 16 bytes larger than the /// [`Vec::len`], to avoid reallocating for the message MAC, which will be appended to the vec. - fn encrypt_message_with_header_0s(&mut self, msgbuf: &mut Vec) { + fn encrypt_message_with_header_0s(&mut self, msgbuf: &mut Vec) -> Result<(), ()> { let msg_len = msgbuf.len() - 16 - 2; if msg_len > LN_MAX_MSG_LEN { - panic!("Attempted to encrypt message longer than 65535 bytes!"); + debug_assert!(false, "Attempted to encrypt message longer than 65535 bytes!"); + return Err(()); } match self.noise_state { @@ -550,30 +551,40 @@ impl PeerChannelEncryptor { Self::encrypt_in_place_with_ad(msgbuf, 16 + 2, *sn, sk, &[0; 0]); *sn += 1; + Ok(()) + }, + _ => { + debug_assert!( + false, + "Tried to encrypt a message prior to noise handshake completion" + ); + Err(()) }, - _ => panic!("Tried to encrypt a message prior to noise handshake completion"), } } /// Encrypts the given pre-serialized message, returning the encrypted version. - /// panics if msg.len() > 65535 or Noise handshake has not finished. pub fn encrypt_buffer(&mut self, mut msg: MessageBuf) -> Vec { - self.encrypt_message_with_header_0s(&mut msg.0); + self.encrypt_message_with_header_0s(&mut msg.0) + .expect("Length was checked in buf constructor and peer should be live"); msg.0 } /// 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: &M) -> Vec { + /// + /// Returns `Err(())` if the length of `message`, once encoded, is greater than 65535 or if the + /// Noise handshake has not finished. + pub(crate) fn encrypt_message( + &mut self, message: &M, + ) -> Result, ()> { // 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)); res.0.resize(16 + 2, 0); wire::write(message, &mut res).expect("In-memory messages must never fail to serialize"); - self.encrypt_message_with_header_0s(&mut res.0); - res.0 + self.encrypt_message_with_header_0s(&mut res.0)?; + Ok(res.0) } /// Decrypts a message length header from the remote peer. @@ -602,10 +613,14 @@ impl PeerChannelEncryptor { /// Decrypts the given message up to msg.len() - 16. Bytes after msg.len() - 16 will be left /// undefined (as they contain the Poly1305 tag bytes). /// - /// panics if msg.len() > 65535 + 16 + /// Returns an error if `msg.len() > 65535 + 16`. pub fn decrypt_message(&mut self, msg: &mut [u8]) -> Result<(), LightningError> { if msg.len() > LN_MAX_MSG_LEN + 16 { - panic!("Attempted to decrypt message longer than 65535 + 16 bytes!"); + debug_assert!(false, "Attempted to decrypt message longer than 65535 + 16 bytes!"); + return Err(LightningError { + err: "Somehow had an oversized message to decrypt".to_owned(), + action: ErrorAction::DisconnectPeer { msg: None }, + }); } match self.noise_state { @@ -649,17 +664,18 @@ impl MessageBuf { /// Creates a new buffer from an encoded message (i.e. the two message-type bytes followed by /// the message contents). /// - /// Panics if the message is longer than 2^16. - pub fn from_encoded(encoded_msg: &[u8]) -> Self { + /// Returns `Err(())` if the message is longer than 2^16 - 1. + pub fn from_encoded(encoded_msg: &[u8]) -> Result { if encoded_msg.len() > LN_MAX_MSG_LEN { - panic!("Attempted to encrypt message longer than 65535 bytes!"); + debug_assert!(false, "Attempted to encrypt message longer than 65535 bytes!"); + return Err(()); } // In addition to the message (continaing the two message type bytes), we also have to add // the message length header (and its MAC) and the message MAC. let mut res = Vec::with_capacity(encoded_msg.len() + 16 * 2 + 2); res.resize(encoded_msg.len() + 16 + 2, 0); res[16 + 2..].copy_from_slice(&encoded_msg); - Self(res) + Ok(Self(res)) } #[cfg(test)] @@ -1015,7 +1031,8 @@ mod tests { for i in 0..1005 { let msg = [0x68, 0x65, 0x6c, 0x6c, 0x6f]; - let mut res = outbound_peer.encrypt_buffer(MessageBuf::from_encoded(&msg)); + let msgbuf = MessageBuf::from_encoded(&msg).unwrap(); + let mut res = outbound_peer.encrypt_buffer(msgbuf); assert_eq!(res.len(), 5 + 2 * 16 + 2); let len_header = res[0..2 + 16].to_vec(); @@ -1056,20 +1073,38 @@ mod tests { } #[test] + #[cfg(debug_assertions)] #[should_panic(expected = "Attempted to encrypt message longer than 65535 bytes!")] fn max_message_len_encryption() { - let mut outbound_peer = get_outbound_peer_for_initiator_test_vectors(); let msg = [4u8; LN_MAX_MSG_LEN + 1]; - outbound_peer.encrypt_buffer(MessageBuf::from_encoded(&msg)); + let _ = MessageBuf::from_encoded(&msg); + } + + #[test] + #[cfg(not(debug_assertions))] + fn max_message_len_encryption() { + let msg = [4u8; LN_MAX_MSG_LEN + 1]; + assert!(MessageBuf::from_encoded(&msg).is_err()); } #[test] + #[cfg(debug_assertions)] #[should_panic(expected = "Attempted to decrypt message longer than 65535 + 16 bytes!")] fn max_message_len_decryption() { let mut inbound_peer = get_inbound_peer_for_test_vectors(); // MSG should not exceed LN_MAX_MSG_LEN + 16 let mut msg = [4u8; LN_MAX_MSG_LEN + 17]; - inbound_peer.decrypt_message(&mut msg).unwrap(); + let _ = inbound_peer.decrypt_message(&mut msg); + } + + #[test] + #[cfg(not(debug_assertions))] + fn max_message_len_decryption() { + let mut inbound_peer = get_inbound_peer_for_test_vectors(); + + // MSG should not exceed LN_MAX_MSG_LEN + 16 + let mut msg = [4u8; LN_MAX_MSG_LEN + 17]; + assert!(inbound_peer.decrypt_message(&mut msg).is_err()); } } diff --git a/lightning/src/ln/peer_handler.rs b/lightning/src/ln/peer_handler.rs index 30f8ec35d88..b7fa6b4deb6 100644 --- a/lightning/src/ln/peer_handler.rs +++ b/lightning/src/ln/peer_handler.rs @@ -1721,7 +1721,7 @@ where debug_assert!(false, "node_id should be set by the time we send a message"); } peer.msgs_sent_since_pong += 1; - peer.pending_outbound_buffer.push_back(peer.channel_encryptor.encrypt_message(message)); + peer.pending_outbound_buffer.push_back(peer.channel_encryptor.encrypt_message(message).expect("TODO: Handled in the next commit")); } fn do_read_event( @@ -2691,8 +2691,9 @@ where { continue; } - let encoded_message = MessageBuf::from_encoded(&encoded_msg); - peer.gossip_broadcast_buffer.push_back(encoded_message); + if let Ok(encoded_message) = MessageBuf::from_encoded(&encoded_msg) { + peer.gossip_broadcast_buffer.push_back(encoded_message); + } } }, wire::Message::NodeAnnouncement(ref msg) => { @@ -2733,8 +2734,9 @@ where { continue; } - let encoded_message = MessageBuf::from_encoded(&encoded_msg); - peer.gossip_broadcast_buffer.push_back(encoded_message); + if let Ok(encoded_message) = MessageBuf::from_encoded(&encoded_msg) { + peer.gossip_broadcast_buffer.push_back(encoded_message); + } } }, wire::Message::ChannelUpdate(ref msg) => { @@ -2770,8 +2772,9 @@ where { continue; } - let encoded_message = MessageBuf::from_encoded(&encoded_msg); - peer.gossip_broadcast_buffer.push_back(encoded_message); + if let Ok(encoded_message) = MessageBuf::from_encoded(&encoded_msg) { + peer.gossip_broadcast_buffer.push_back(encoded_message); + } } }, _ => { @@ -4251,7 +4254,7 @@ mod tests { peers[0].read_event(&mut fd_dup, &act_three).unwrap(); let not_init_msg = msgs::Ping { ponglen: 4, byteslen: 0 }; - let msg_bytes = dup_encryptor.encrypt_message(¬_init_msg); + let msg_bytes = dup_encryptor.encrypt_message(¬_init_msg).unwrap(); assert!(peers[0].read_event(&mut fd_dup, &msg_bytes).is_err()); } From 52377d4780e9cd9339198729c06c34f35138d37a Mon Sep 17 00:00:00 2001 From: Matt Corallo Date: Tue, 4 Aug 2026 22:11:27 +0000 Subject: [PATCH 092/135] Avoid panicking when attempting to send an oversized message While this code should remain unreachable as it likely indicates we're going to end up force-closing a channel due to being unable to communicate with a peer, we shouldn't bring down the whole process for it if we can avoid it. Co-Authored-By: Claude Backport of c5fdc3bf018c702bf6815f45b0496df1966dcf2f Conflicts resolved in: * lightning/src/ln/peer_handler.rs --- lightning/src/ln/peer_handler.rs | 233 +++++++++++++++++++------------ 1 file changed, 144 insertions(+), 89 deletions(-) diff --git a/lightning/src/ln/peer_handler.rs b/lightning/src/ln/peer_handler.rs index b7fa6b4deb6..be0ea6c1dc6 100644 --- a/lightning/src/ln/peer_handler.rs +++ b/lightning/src/ln/peer_handler.rs @@ -1553,7 +1553,8 @@ where if let Some(next_onion_message) = handler.next_onion_message_for_peer(peer_node_id) { - self.enqueue_message(peer, &next_onion_message); + // OMs are delivered on a best-effort basis, drop if unsendable. + let _ = self.enqueue_message(peer, &next_onion_message); } } } @@ -1571,12 +1572,12 @@ where if let Some((announce, update_a_option, update_b_option)) = self.message_handler.route_handler.get_next_channel_announcement(c) { - self.enqueue_message(peer, &announce); + let _ = self.enqueue_message(peer, &announce); if let Some(update_a) = update_a_option { - self.enqueue_message(peer, &update_a); + let _ = self.enqueue_message(peer, &update_a); } if let Some(update_b) = update_b_option { - self.enqueue_message(peer, &update_b); + let _ = self.enqueue_message(peer, &update_b); } peer.sync_status = InitSyncTracker::ChannelsSyncing( announce.contents.short_channel_id + 1, @@ -1589,7 +1590,7 @@ where InitSyncTracker::ChannelsSyncing(c) if c == 0xffff_ffff_ffff_ffff => { let handler = &self.message_handler.route_handler; if let Some(msg) = handler.get_next_node_announcement(None) { - self.enqueue_message(peer, &msg); + let _ = self.enqueue_message(peer, &msg); peer.sync_status = InitSyncTracker::NodesSyncing(msg.contents.node_id); } else { peer.sync_status = InitSyncTracker::NoSyncRequested; @@ -1599,7 +1600,7 @@ where InitSyncTracker::NodesSyncing(sync_node_id) => { let handler = &self.message_handler.route_handler; if let Some(msg) = handler.get_next_node_announcement(Some(&sync_node_id)) { - self.enqueue_message(peer, &msg); + let _ = self.enqueue_message(peer, &msg); peer.sync_status = InitSyncTracker::NodesSyncing(msg.contents.node_id); } else { peer.sync_status = InitSyncTracker::NoSyncRequested; @@ -1708,10 +1709,13 @@ where } /// Append a message to a peer's pending outbound/write buffer - fn enqueue_message(&self, peer: &mut Peer, message: &M) { + /// + /// Returns `Err(())` if the message was too large to send (and was thus dropped). This may + /// result in us getting out of sync with the peer! + fn enqueue_message(&self, peer: &mut Peer, message: &M) -> Result<(), ()> { let their_node_id = peer.their_node_id.map(|p| p.0); + let logger = WithContext::from(&self.logger, their_node_id, None, None); if let Some(node_id) = their_node_id { - let logger = WithContext::from(&self.logger, their_node_id, None, None); if is_gossip_msg(message.type_id()) { log_gossip!(logger, "Enqueueing message {:?} to {}", message, node_id); } else { @@ -1721,7 +1725,17 @@ where debug_assert!(false, "node_id should be set by the time we send a message"); } peer.msgs_sent_since_pong += 1; - peer.pending_outbound_buffer.push_back(peer.channel_encryptor.encrypt_message(message).expect("TODO: Handled in the next commit")); + let msg_ty = message.type_id(); + match peer.channel_encryptor.encrypt_message(message) { + Ok(encrypted_msg) => { + peer.pending_outbound_buffer.push_back(encrypted_msg); + Ok(()) + }, + Err(()) => { + log_error!(logger, "Failed to encrypt a message of type {}, dropping it!", msg_ty); + Err(()) + }, + } } fn do_read_event( @@ -1773,12 +1787,12 @@ where }, msgs::ErrorAction::SendErrorMessage { msg } => { log_debug!(logger, "Error handling message{}; sending error message with: {}", OptionalFromDebugger(&peer_node_id), e.err); - self.enqueue_message($peer, &msg); + let _ = self.enqueue_message($peer, &msg); continue; }, msgs::ErrorAction::SendWarningMessage { msg, log_level } => { log_given_level!(logger, log_level, "Error handling message{}; sending warning message with: {}", OptionalFromDebugger(&peer_node_id), e.err); - self.enqueue_message($peer, &msg); + let _ = self.enqueue_message($peer, &msg); continue; }, } @@ -1873,7 +1887,7 @@ where peer.their_socket_address.clone(), ), }; - self.enqueue_message(peer, &resp); + self.enqueue_message(peer, &resp).map_err(|()| PeerHandleError {})?; }, NextNoiseStep::ActThree => { let res = peer @@ -1893,7 +1907,7 @@ where peer.their_socket_address.clone(), ), }; - self.enqueue_message(peer, &resp); + self.enqueue_message(peer, &resp).map_err(|()| PeerHandleError {})?; }, NextNoiseStep::NoiseComplete => { if peer.pending_read_is_header { @@ -1954,7 +1968,7 @@ where let data = "Unsupported message compression: zlib" .to_owned(); let msg = msgs::WarningMessage { channel_id, data }; - self.enqueue_message(peer, &msg); + let _ = self.enqueue_message(peer, &msg); continue; }, (_, Some(ty)) if is_gossip_msg(ty) => { @@ -1965,7 +1979,7 @@ where ty ); let msg = msgs::WarningMessage { channel_id, data }; - self.enqueue_message(peer, &msg); + let _ = self.enqueue_message(peer, &msg); continue; }, (msgs::DecodeError::UnknownRequiredFeature, _) => { @@ -2433,7 +2447,7 @@ where wire::Message::Ping(msg) => { if msg.ponglen < 65532 { let resp = msgs::Pong { byteslen: msg.ponglen }; - self.enqueue_message(&mut *peer_mutex.lock().unwrap(), &resp); + let _ = self.enqueue_message(&mut *peer_mutex.lock().unwrap(), &resp); } }, wire::Message::Pong(_msg) => { @@ -2852,6 +2866,27 @@ where }}; } + macro_rules! enqueue_message_to_peer { + ($peer: expr, $node_id: expr, $msg: expr) => {{ + if self.enqueue_message($peer, $msg).is_err() { + peers_to_disconnect + .insert(*$node_id, (None, "a message we failed to send")); + None + } else { + Some(()) + } + }}; + } + + macro_rules! enqueue_message_to { + ($node_id: expr, $msg: expr) => {{ + match get_peer_for_forwarding!($node_id) { + Some(mut peer) => enqueue_message_to_peer!(&mut *peer, $node_id, $msg), + None => None, + } + }}; + } + let route_handler = &self.message_handler.route_handler; let chan_handler = &self.message_handler.chan_handler; let onion_message_handler = &self.message_handler.onion_message_handler; @@ -2868,7 +2903,7 @@ where "Handling SendPeerStorage event in peer_handler for {}", node_id, ); - self.enqueue_message(&mut *get_peer_for_forwarding!(node_id)?, msg); + enqueue_message_to!(node_id, msg)?; }, MessageSendEvent::SendPeerStorageRetrieval { ref node_id, ref msg } => { log_debug!( @@ -2876,31 +2911,31 @@ where "Handling SendPeerStorageRetrieval event in peer_handler for {}", node_id, ); - self.enqueue_message(&mut *get_peer_for_forwarding!(node_id)?, msg); + enqueue_message_to!(node_id, msg)?; }, MessageSendEvent::SendAcceptChannel { ref node_id, ref msg } => { log_debug!(WithContext::from(&self.logger, Some(*node_id), Some(msg.common_fields.temporary_channel_id), None), "Handling SendAcceptChannel event in peer_handler for node {} for channel {}", node_id, &msg.common_fields.temporary_channel_id); - self.enqueue_message(&mut *get_peer_for_forwarding!(node_id)?, msg); + enqueue_message_to!(node_id, msg)?; }, MessageSendEvent::SendAcceptChannelV2 { ref node_id, ref msg } => { log_debug!(WithContext::from(&self.logger, Some(*node_id), Some(msg.common_fields.temporary_channel_id), None), "Handling SendAcceptChannelV2 event in peer_handler for node {} for channel {}", node_id, &msg.common_fields.temporary_channel_id); - self.enqueue_message(&mut *get_peer_for_forwarding!(node_id)?, msg); + enqueue_message_to!(node_id, msg)?; }, MessageSendEvent::SendOpenChannel { ref node_id, ref msg } => { log_debug!(WithContext::from(&self.logger, Some(*node_id), Some(msg.common_fields.temporary_channel_id), None), "Handling SendOpenChannel event in peer_handler for node {} for channel {}", node_id, &msg.common_fields.temporary_channel_id); - self.enqueue_message(&mut *get_peer_for_forwarding!(node_id)?, msg); + enqueue_message_to!(node_id, msg)?; }, MessageSendEvent::SendOpenChannelV2 { ref node_id, ref msg } => { log_debug!(WithContext::from(&self.logger, Some(*node_id), Some(msg.common_fields.temporary_channel_id), None), "Handling SendOpenChannelV2 event in peer_handler for node {} for channel {}", node_id, &msg.common_fields.temporary_channel_id); - self.enqueue_message(&mut *get_peer_for_forwarding!(node_id)?, msg); + enqueue_message_to!(node_id, msg)?; }, MessageSendEvent::SendFundingCreated { ref node_id, ref msg } => { log_debug!(WithContext::from(&self.logger, Some(*node_id), Some(msg.temporary_channel_id), None), "Handling SendFundingCreated event in peer_handler for node {} for channel {} (which becomes {})", @@ -2909,19 +2944,19 @@ where ChannelId::v1_from_funding_txid(msg.funding_txid.as_byte_array(), msg.funding_output_index)); // TODO: If the peer is gone we should generate a DiscardFunding event // indicating to the wallet that they should just throw away this funding transaction - self.enqueue_message(&mut *get_peer_for_forwarding!(node_id)?, msg); + enqueue_message_to!(node_id, msg)?; }, MessageSendEvent::SendFundingSigned { ref node_id, ref msg } => { log_debug!(WithContext::from(&self.logger, Some(*node_id), Some(msg.channel_id), None), "Handling SendFundingSigned event in peer_handler for node {} for channel {}", node_id, &msg.channel_id); - self.enqueue_message(&mut *get_peer_for_forwarding!(node_id)?, msg); + enqueue_message_to!(node_id, msg)?; }, MessageSendEvent::SendChannelReady { ref node_id, ref msg } => { log_debug!(WithContext::from(&self.logger, Some(*node_id), Some(msg.channel_id), None), "Handling SendChannelReady event in peer_handler for node {} for channel {}", node_id, &msg.channel_id); - self.enqueue_message(&mut *get_peer_for_forwarding!(node_id)?, msg); + enqueue_message_to!(node_id, msg)?; }, MessageSendEvent::SendStfu { ref node_id, ref msg } => { let logger = WithContext::from( @@ -2933,7 +2968,7 @@ where log_debug!(logger, "Handling SendStfu event in peer_handler for node {} for channel {}", node_id, &msg.channel_id); - self.enqueue_message(&mut *get_peer_for_forwarding!(node_id)?, msg); + enqueue_message_to!(node_id, msg)?; }, MessageSendEvent::SendSpliceInit { ref node_id, ref msg } => { let logger = WithContext::from( @@ -2945,7 +2980,7 @@ where log_debug!(logger, "Handling SendSpliceInit event in peer_handler for node {} for channel {}", node_id, &msg.channel_id); - self.enqueue_message(&mut *get_peer_for_forwarding!(node_id)?, msg); + enqueue_message_to!(node_id, msg)?; }, MessageSendEvent::SendSpliceAck { ref node_id, ref msg } => { let logger = WithContext::from( @@ -2957,7 +2992,7 @@ where log_debug!(logger, "Handling SendSpliceAck event in peer_handler for node {} for channel {}", node_id, &msg.channel_id); - self.enqueue_message(&mut *get_peer_for_forwarding!(node_id)?, msg); + enqueue_message_to!(node_id, msg)?; }, MessageSendEvent::SendSpliceLocked { ref node_id, ref msg } => { let logger = WithContext::from( @@ -2969,67 +3004,67 @@ where log_debug!(logger, "Handling SendSpliceLocked event in peer_handler for node {} for channel {}", node_id, &msg.channel_id); - self.enqueue_message(&mut *get_peer_for_forwarding!(node_id)?, msg); + enqueue_message_to!(node_id, msg)?; }, MessageSendEvent::SendTxAddInput { ref node_id, ref msg } => { log_debug!(WithContext::from(&self.logger, Some(*node_id), Some(msg.channel_id), None), "Handling SendTxAddInput event in peer_handler for node {} for channel {}", node_id, &msg.channel_id); - self.enqueue_message(&mut *get_peer_for_forwarding!(node_id)?, msg); + enqueue_message_to!(node_id, msg)?; }, MessageSendEvent::SendTxAddOutput { ref node_id, ref msg } => { log_debug!(WithContext::from(&self.logger, Some(*node_id), Some(msg.channel_id), None), "Handling SendTxAddOutput event in peer_handler for node {} for channel {}", node_id, &msg.channel_id); - self.enqueue_message(&mut *get_peer_for_forwarding!(node_id)?, msg); + enqueue_message_to!(node_id, msg)?; }, MessageSendEvent::SendTxRemoveInput { ref node_id, ref msg } => { log_debug!(WithContext::from(&self.logger, Some(*node_id), Some(msg.channel_id), None), "Handling SendTxRemoveInput event in peer_handler for node {} for channel {}", node_id, &msg.channel_id); - self.enqueue_message(&mut *get_peer_for_forwarding!(node_id)?, msg); + enqueue_message_to!(node_id, msg)?; }, MessageSendEvent::SendTxRemoveOutput { ref node_id, ref msg } => { log_debug!(WithContext::from(&self.logger, Some(*node_id), Some(msg.channel_id), None), "Handling SendTxRemoveOutput event in peer_handler for node {} for channel {}", node_id, &msg.channel_id); - self.enqueue_message(&mut *get_peer_for_forwarding!(node_id)?, msg); + enqueue_message_to!(node_id, msg)?; }, MessageSendEvent::SendTxComplete { ref node_id, ref msg } => { log_debug!(WithContext::from(&self.logger, Some(*node_id), Some(msg.channel_id), None), "Handling SendTxComplete event in peer_handler for node {} for channel {}", node_id, &msg.channel_id); - self.enqueue_message(&mut *get_peer_for_forwarding!(node_id)?, msg); + enqueue_message_to!(node_id, msg)?; }, MessageSendEvent::SendTxSignatures { ref node_id, ref msg } => { log_debug!(WithContext::from(&self.logger, Some(*node_id), Some(msg.channel_id), None), "Handling SendTxSignatures event in peer_handler for node {} for channel {}", node_id, &msg.channel_id); - self.enqueue_message(&mut *get_peer_for_forwarding!(node_id)?, msg); + enqueue_message_to!(node_id, msg)?; }, MessageSendEvent::SendTxInitRbf { ref node_id, ref msg } => { log_debug!(WithContext::from(&self.logger, Some(*node_id), Some(msg.channel_id), None), "Handling SendTxInitRbf event in peer_handler for node {} for channel {}", node_id, &msg.channel_id); - self.enqueue_message(&mut *get_peer_for_forwarding!(node_id)?, msg); + enqueue_message_to!(node_id, msg)?; }, MessageSendEvent::SendTxAckRbf { ref node_id, ref msg } => { log_debug!(WithContext::from(&self.logger, Some(*node_id), Some(msg.channel_id), None), "Handling SendTxAckRbf event in peer_handler for node {} for channel {}", node_id, &msg.channel_id); - self.enqueue_message(&mut *get_peer_for_forwarding!(node_id)?, msg); + enqueue_message_to!(node_id, msg)?; }, MessageSendEvent::SendTxAbort { ref node_id, ref msg } => { log_debug!(WithContext::from(&self.logger, Some(*node_id), Some(msg.channel_id), None), "Handling SendTxAbort event in peer_handler for node {} for channel {}", node_id, &msg.channel_id); - self.enqueue_message(&mut *get_peer_for_forwarding!(node_id)?, msg); + enqueue_message_to!(node_id, msg)?; }, MessageSendEvent::SendAnnouncementSignatures { ref node_id, ref msg } => { log_debug!(WithContext::from(&self.logger, Some(*node_id), Some(msg.channel_id), None), "Handling SendAnnouncementSignatures event in peer_handler for node {} for channel {})", node_id, &msg.channel_id); - self.enqueue_message(&mut *get_peer_for_forwarding!(node_id)?, msg); + enqueue_message_to!(node_id, msg)?; }, MessageSendEvent::UpdateHTLCs { ref node_id, @@ -3053,19 +3088,19 @@ where channel_id); let mut peer = get_peer_for_forwarding!(node_id)?; for msg in update_fulfill_htlcs { - self.enqueue_message(&mut *peer, msg); + enqueue_message_to_peer!(&mut *peer, node_id, msg)?; } for msg in update_fail_htlcs { - self.enqueue_message(&mut *peer, msg); + enqueue_message_to_peer!(&mut *peer, node_id, msg)?; } for msg in update_fail_malformed_htlcs { - self.enqueue_message(&mut *peer, msg); + enqueue_message_to_peer!(&mut *peer, node_id, msg)?; } for msg in update_add_htlcs { - self.enqueue_message(&mut *peer, msg); + enqueue_message_to_peer!(&mut *peer, node_id, msg)?; } - if let &Some(ref msg) = update_fee { - self.enqueue_message(&mut *peer, msg); + if let Some(msg) = update_fee { + enqueue_message_to_peer!(&mut *peer, node_id, msg)?; } if commitment_signed.len() > 1 { let msg = msgs::StartBatch { @@ -3073,47 +3108,47 @@ where batch_size: commitment_signed.len() as u16, message_type: Some(msgs::CommitmentSigned::TYPE), }; - self.enqueue_message(&mut *peer, &msg); + enqueue_message_to_peer!(&mut *peer, node_id, &msg)?; } for msg in commitment_signed { - self.enqueue_message(&mut *peer, msg); + enqueue_message_to_peer!(&mut *peer, node_id, msg)?; } }, MessageSendEvent::SendRevokeAndACK { ref node_id, ref msg } => { log_debug!(WithContext::from(&self.logger, Some(*node_id), Some(msg.channel_id), None), "Handling SendRevokeAndACK event in peer_handler for node {} for channel {}", node_id, &msg.channel_id); - self.enqueue_message(&mut *get_peer_for_forwarding!(node_id)?, msg); + enqueue_message_to!(node_id, msg)?; }, MessageSendEvent::SendClosingSigned { ref node_id, ref msg } => { log_debug!(WithContext::from(&self.logger, Some(*node_id), Some(msg.channel_id), None), "Handling SendClosingSigned event in peer_handler for node {} for channel {}", node_id, &msg.channel_id); - self.enqueue_message(&mut *get_peer_for_forwarding!(node_id)?, msg); + enqueue_message_to!(node_id, msg)?; }, MessageSendEvent::SendClosingComplete { ref node_id, ref msg } => { log_debug!(WithContext::from(&self.logger, Some(*node_id), Some(msg.channel_id), None), "Handling SendClosingComplete event in peer_handler for node {} for channel {}", node_id, &msg.channel_id); - self.enqueue_message(&mut *get_peer_for_forwarding!(node_id)?, msg); + enqueue_message_to!(node_id, msg)?; }, MessageSendEvent::SendClosingSig { ref node_id, ref msg } => { log_debug!(WithContext::from(&self.logger, Some(*node_id), Some(msg.channel_id), None), "Handling SendClosingSig event in peer_handler for node {} for channel {}", node_id, &msg.channel_id); - self.enqueue_message(&mut *get_peer_for_forwarding!(node_id)?, msg); + enqueue_message_to!(node_id, msg)?; }, MessageSendEvent::SendShutdown { ref node_id, ref msg } => { log_debug!(WithContext::from(&self.logger, Some(*node_id), Some(msg.channel_id), None), "Handling Shutdown event in peer_handler for node {} for channel {}", node_id, &msg.channel_id); - self.enqueue_message(&mut *get_peer_for_forwarding!(node_id)?, msg); + enqueue_message_to!(node_id, msg)?; }, MessageSendEvent::SendChannelReestablish { ref node_id, ref msg } => { log_debug!(WithContext::from(&self.logger, Some(*node_id), Some(msg.channel_id), None), "Handling SendChannelReestablish event in peer_handler for node {} for channel {}", node_id, &msg.channel_id); - self.enqueue_message(&mut *get_peer_for_forwarding!(node_id)?, msg); + enqueue_message_to!(node_id, msg)?; }, MessageSendEvent::SendChannelAnnouncement { ref node_id, @@ -3123,11 +3158,8 @@ where log_debug!(WithContext::from(&self.logger, Some(*node_id), None, None), "Handling SendChannelAnnouncement event in peer_handler for node {} for short channel id {}", node_id, msg.contents.short_channel_id); - self.enqueue_message(&mut *get_peer_for_forwarding!(node_id)?, msg); - self.enqueue_message( - &mut *get_peer_for_forwarding!(node_id)?, - update_msg, - ); + enqueue_message_to!(node_id, msg)?; + enqueue_message_to!(node_id, update_msg)?; }, MessageSendEvent::BroadcastChannelAnnouncement { msg, update_msg } => { log_debug!(self.logger, "Handling BroadcastChannelAnnouncement event in peer_handler for short channel id {}", msg.contents.short_channel_id); @@ -3207,7 +3239,7 @@ where MessageSendEvent::SendChannelUpdate { ref node_id, ref msg } => { log_trace!(WithContext::from(&self.logger, Some(*node_id), None, None), "Handling SendChannelUpdate event in peer_handler for node {} for channel {}", node_id, msg.contents.short_channel_id); - self.enqueue_message(&mut *get_peer_for_forwarding!(node_id)?, msg); + enqueue_message_to!(node_id, msg)?; }, MessageSendEvent::HandleError { node_id, action } => { let logger = WithContext::from(&self.logger, Some(node_id), None, None); @@ -3226,7 +3258,7 @@ where let msg = msg.map(|msg| { wire::Message::<<::Target as wire::CustomMessageReader>::CustomMessage>::Error(msg) }); - peers_to_disconnect.insert(node_id, msg); + peers_to_disconnect.insert(node_id, (msg, "DisconnectPeer HandleError")); }, msgs::ErrorAction::DisconnectPeerWithWarning { msg } => { log_trace!(logger, "Handling DisconnectPeer HandleError event in peer_handler for node {} with message {}", @@ -3234,8 +3266,12 @@ where // 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. - peers_to_disconnect - .insert(node_id, Some(wire::Message::Warning(msg))); + peers_to_disconnect.insert( + node_id, ( + Some(wire::Message::Warning(msg)), + "DisconnectPeerWithWarning HandleError", + ), + ); }, msgs::ErrorAction::IgnoreAndLog(level) => { log_given_level!( @@ -3257,10 +3293,7 @@ where log_trace!(logger, "Handling SendErrorMessage HandleError event in peer_handler for node {} with message {}", node_id, log_msg!(msg.data)); - self.enqueue_message( - &mut *get_peer_for_forwarding!(&node_id)?, - msg, - ); + enqueue_message_to!(&node_id, msg)?; }, msgs::ErrorAction::SendWarningMessage { ref msg, @@ -3269,10 +3302,7 @@ where log_given_level!(logger, *log_level, "Handling SendWarningMessage HandleError event in peer_handler for node {} with message {}", node_id, log_msg!(msg.data)); - self.enqueue_message( - &mut *get_peer_for_forwarding!(&node_id)?, - msg, - ); + enqueue_message_to!(&node_id, msg)?; }, } }, @@ -3281,13 +3311,13 @@ where node_id, msg.first_blocknum, msg.number_of_blocks); - self.enqueue_message(&mut *get_peer_for_forwarding!(node_id)?, msg); + enqueue_message_to!(node_id, msg)?; }, MessageSendEvent::SendShortIdsQuery { ref node_id, ref msg } => { log_gossip!(WithContext::from(&self.logger, Some(*node_id), None, None), "Handling SendShortIdsQuery event in peer_handler for node {} with num_scids={}", node_id, msg.short_channel_ids.len()); - self.enqueue_message(&mut *get_peer_for_forwarding!(node_id)?, msg); + enqueue_message_to!(node_id, msg)?; }, MessageSendEvent::SendReplyChannelRange { ref node_id, ref msg } => { log_gossip!(WithContext::from(&self.logger, Some(*node_id), None, None), "Handling SendReplyChannelRange event in peer_handler for node {} with num_scids={} first_blocknum={} number_of_blocks={}, sync_complete={}", @@ -3296,14 +3326,14 @@ where msg.first_blocknum, msg.number_of_blocks, msg.sync_complete); - self.enqueue_message(&mut *get_peer_for_forwarding!(node_id)?, msg); + enqueue_message_to!(node_id, msg)?; }, MessageSendEvent::SendGossipTimestampFilter { ref node_id, ref msg } => { log_gossip!(WithContext::from(&self.logger, Some(*node_id), None, None), "Handling SendGossipTimestampFilter event in peer_handler for node {} with first_timestamp={}, timestamp_range={}", node_id, msg.first_timestamp, msg.timestamp_range); - self.enqueue_message(&mut *get_peer_for_forwarding!(node_id)?, msg); + enqueue_message_to!(node_id, msg)?; }, } Some(()) @@ -3330,15 +3360,7 @@ where } for (node_id, msg) in custom_message_handler.get_and_clear_pending_msg() { - if peers_to_disconnect.get(&node_id).is_some() { - continue; - } - let mut peer = if let Some(peer) = get_peer_for_forwarding!(&node_id) { - peer - } else { - continue; - }; - self.enqueue_message(&mut peer, &msg); + enqueue_message_to!(&node_id, &msg); } for (descriptor, peer_mutex) in peers.iter() { @@ -3356,7 +3378,7 @@ where if !peers_to_disconnect.is_empty() { let mut peers_lock = self.peers.write().unwrap(); let peers = &mut *peers_lock; - for (node_id, msg) in peers_to_disconnect.drain() { + for (node_id, (msg, reason)) in peers_to_disconnect.drain() { // Note that since we are holding the peers *write* lock we can // remove from node_id_to_descriptor immediately (as no other // thread can be holding the peer lock if we have the global write @@ -3368,12 +3390,12 @@ where if let Some(peer_mutex) = peers.remove(&descriptor) { let mut peer = peer_mutex.lock().unwrap(); if let Some(msg) = msg { - self.enqueue_message(&mut *peer, &msg); + let _ = self.enqueue_message(&mut *peer, &msg); // This isn't guaranteed to work, but if there is enough free // room in the send buffer, put the error message there... self.do_attempt_write_data(&mut descriptor, &mut *peer, false); } - self.do_disconnect(descriptor, &*peer, "DisconnectPeer HandleError"); + self.do_disconnect(descriptor, &*peer, reason); } else { debug_assert!(false, "Missing connection for peer"); } @@ -3499,7 +3521,7 @@ where if peer.awaiting_pong_timer_tick_intervals == 0 { peer.awaiting_pong_timer_tick_intervals = -1; let ping = msgs::Ping { ponglen: 0, byteslen: 64 }; - self.enqueue_message(peer, &ping); + let _ = self.enqueue_message(peer, &ping); } } @@ -3570,7 +3592,7 @@ where peer.awaiting_pong_timer_tick_intervals = 1; let ping = msgs::Ping { ponglen: 0, byteslen: 64 }; - self.enqueue_message(&mut *peer, &ping); + let _ = self.enqueue_message(&mut *peer, &ping); break; } self.do_attempt_write_data( @@ -4361,6 +4383,39 @@ mod tests { } } + // An oversized message trips a `debug_assert` in the encryptor before the error makes it back to + // us, so we can only observe the graceful handling with debug assertions disabled. + #[test] + #[cfg(not(debug_assertions))] + fn test_message_send_failure_disconnects() { + // Test that a peer we failed to send a message to gets disconnected, ensuring they know to + // resync rather than waiting forever on a message which is never going to arrive. + let cfgs = create_peermgr_cfgs(2); + let peers = create_network(2, &cfgs); + let (fd_a, _fd_b) = establish_connection(&peers[0], &peers[1]); + + let id_b = peers[1].node_signer.get_node_id(Recipient::Node).unwrap(); + assert!(peers[0].peer_by_node_id(&id_b).is_some()); + assert!(!fd_a.disconnect.load(Ordering::Acquire)); + + // Queue an `error` which is too long to fit on the wire, which we'll thus fail to send. + let len = crate::ln::peer_channel_encryptor::LN_MAX_MSG_LEN + 1; + let msg = msgs::ErrorMessage { channel_id: ChannelId([0; 32]), data: "A".repeat(len) }; + let action = msgs::ErrorAction::SendErrorMessage { msg }; + let event = MessageSendEvent::HandleError { node_id: id_b, action }; + cfgs[0].chan_handler.pending_events.lock().unwrap().push(event); + + peers[0].process_events(); + + assert!(fd_a.disconnect.load(Ordering::Acquire)); + assert!(peers[0].list_peers().is_empty()); + assert!(peers[0].peer_by_node_id(&id_b).is_none()); + + // The message handlers should have been informed of the disconnection. + assert!(cfgs[0].chan_handler.conn_tracker.connected_peers.lock().unwrap().is_empty()); + assert!(cfgs[0].routing_handler.conn_tracker.connected_peers.lock().unwrap().is_empty()); + } + #[test] fn test_do_attempt_write_data() { // Create 2 peers with custom TestRoutingMessageHandlers and connect them. @@ -4632,7 +4687,7 @@ mod tests { channel_id: ChannelId([0; 32]), data: "no disconnect plz".to_string(), }, - ); + ).unwrap(); } peer_a.process_events(); let msg = fd_a.outbound_data.lock().unwrap().split_off(0); From ac2a230f7cb82390c42b5d53636ef4a2d87187af Mon Sep 17 00:00:00 2001 From: Matt Corallo Date: Tue, 4 Aug 2026 13:40:22 +0000 Subject: [PATCH 093/135] Correct HTLC confusion on unrevoked counterparty commitment txs When we see an HTLC on an unrevoked counterparty commitment transaction we check if it has a corresponding `HTLCSource` when deciding whether it is likely from a revoked counterparty commitment transaction. This is insufficient, however, and can result in us assuming that an HTLC is on a revoked counterparty commitment (and thus is safe to fail back based on a loose HTLC match) rather than on a live one. Here we correct this check and also expand several debug assertions that validated the detection of the claim style against the HTLC type into full-blown assertions. If we end up hitting these, they're almost certainly an indication that something has gone horribly wrong and either keys have leaked or the node software is confused and continuing would be unsafe. Backport of f06a08a645ebc99c06fbb296402ef9403366f7fa --- lightning/src/chain/channelmonitor.rs | 43 ++++++++++++++++----------- lightning/src/ln/chan_utils.rs | 2 +- 2 files changed, 26 insertions(+), 19 deletions(-) diff --git a/lightning/src/chain/channelmonitor.rs b/lightning/src/chain/channelmonitor.rs index e52b05036ec..7727366c2ad 100644 --- a/lightning/src/chain/channelmonitor.rs +++ b/lightning/src/chain/channelmonitor.rs @@ -6118,18 +6118,6 @@ impl ChannelMonitorImpl { macro_rules! log_claim { ($tx_info: expr, $holder_tx: expr, $htlc: expr, $source_avail: expr) => { let outbound_htlc = $holder_tx == $htlc.offered; - // HTLCs must either be claimed by a matching script type or through the - // revocation path: - #[cfg(not(fuzzing))] // Note that the fuzzer is not bound by pesky things like "signatures" - debug_assert!(!$htlc.offered || offered_preimage_claim || offered_timeout_claim || revocation_sig_claim); - #[cfg(not(fuzzing))] // Note that the fuzzer is not bound by pesky things like "signatures" - debug_assert!($htlc.offered || accepted_preimage_claim || accepted_timeout_claim || revocation_sig_claim); - // Further, only exactly one of the possible spend paths should have been - // matched by any HTLC spend: - #[cfg(not(fuzzing))] // Note that the fuzzer is not bound by pesky things like "signatures" - debug_assert_eq!(accepted_preimage_claim as u8 + accepted_timeout_claim as u8 + - offered_preimage_claim as u8 + offered_timeout_claim as u8 + - revocation_sig_claim as u8, 1); if ($holder_tx && revocation_sig_claim) || (outbound_htlc && !$source_avail && (accepted_preimage_claim || offered_preimage_claim)) { log_error!(logger, "Input spending {} ({}:{}) in {} resolves {} HTLC with payment hash {} with {}!", @@ -6142,13 +6130,25 @@ impl ChannelMonitorImpl { if outbound_htlc { "outbound" } else { "inbound" }, &$htlc.payment_hash, if revocation_sig_claim { "revocation sig" } else if accepted_preimage_claim || offered_preimage_claim { "preimage" } else { "timeout" }); } + // HTLCs must either be claimed by a matching script type or through the + // revocation path: + #[cfg(not(fuzzing))] // Note that the fuzzer is not bound by pesky things like "signatures" + assert!(!$htlc.offered || offered_preimage_claim || offered_timeout_claim || revocation_sig_claim, "offered {htlc_claim:?}"); + #[cfg(not(fuzzing))] // Note that the fuzzer is not bound by pesky things like "signatures" + assert!($htlc.offered || accepted_preimage_claim || accepted_timeout_claim || revocation_sig_claim, "!offered {htlc_claim:?}"); + // Further, only exactly one of the possible spend paths should have been + // matched by any HTLC spend: + #[cfg(not(fuzzing))] // Note that the fuzzer is not bound by pesky things like "signatures" + assert_eq!(accepted_preimage_claim as u8 + accepted_timeout_claim as u8 + + offered_preimage_claim as u8 + offered_timeout_claim as u8 + + revocation_sig_claim as u8, 1); } } macro_rules! check_htlc_valid_counterparty { ($htlc_output: expr, $per_commitment_data: expr) => { for &(ref pending_htlc, ref pending_source) in $per_commitment_data { - if pending_htlc.payment_hash == $htlc_output.payment_hash && pending_htlc.amount_msat == $htlc_output.amount_msat { + if pending_htlc.offered == $htlc_output.offered && pending_htlc.payment_hash == $htlc_output.payment_hash && pending_htlc.amount_msat == $htlc_output.amount_msat { if let &Some(ref source) = pending_source { log_claim!("revoked counterparty commitment tx", false, pending_htlc, true); payment_data = Some(((**source).clone(), $htlc_output.payment_hash, $htlc_output.amount_msat)); @@ -6160,7 +6160,7 @@ impl ChannelMonitorImpl { } macro_rules! scan_commitment { - ($funding_spent: expr, $htlcs: expr, $tx_info: expr, $holder_tx: expr) => { + ($funding_spent: expr, $htlcs: expr, $tx_info: expr, $holder_tx: expr, $spent_counterparty_revoked: expr) => { for (ref htlc_output, source_option) in $htlcs { if Some(input.previous_output.vout) == htlc_output.transaction_output_index { if let Some(ref source) = source_option { @@ -6171,7 +6171,7 @@ impl ChannelMonitorImpl { // has timed out, or we screwed up. In any case, we should now // resolve the source HTLC with the original sender. payment_data = Some(((*source).clone(), htlc_output.payment_hash, htlc_output.amount_msat)); - } else if !$holder_tx { + } else if $spent_counterparty_revoked { if let Some(current_counterparty_commitment_txid) = &$funding_spent.current_counterparty_commitment_txid { check_htlc_valid_counterparty!(htlc_output, $funding_spent.counterparty_claimable_outpoints.get(current_counterparty_commitment_txid).unwrap()); } @@ -6208,21 +6208,28 @@ impl ChannelMonitorImpl { if input.previous_output.txid == funding_spent.current_holder_commitment_tx.trust().txid() { scan_commitment!( funding_spent, holder_commitment_htlcs!(self, CURRENT_WITH_SOURCES), - "our latest holder commitment tx", true + "our latest holder commitment tx", true, false ); } if let Some(prev_holder_commitment_tx) = funding_spent.prev_holder_commitment_tx.as_ref() { if input.previous_output.txid == prev_holder_commitment_tx.trust().txid() { scan_commitment!( funding_spent, holder_commitment_htlcs!(self, PREV_WITH_SOURCES).unwrap(), - "our previous holder commitment tx", true + "our previous holder commitment tx", true, false ); } } if let Some(ref htlc_outputs) = funding_spent.counterparty_claimable_outpoints.get(&input.previous_output.txid) { + let mut spent_counterparty_revoked = true; + if funding_spent.current_counterparty_commitment_txid == Some(input.previous_output.txid) { + spent_counterparty_revoked = false; + } + if funding_spent.prev_counterparty_commitment_txid == Some(input.previous_output.txid) { + spent_counterparty_revoked = false; + } let htlcs = htlc_outputs.iter() .map(|&(ref a, ref b)| (a, b.as_ref().map(|boxed| &**boxed))); - scan_commitment!(funding_spent, htlcs, "counterparty commitment tx", false); + scan_commitment!(funding_spent, htlcs, "counterparty commitment tx", false, spent_counterparty_revoked); } // Check that scan_commitment, above, decided there is some source worth relaying an diff --git a/lightning/src/ln/chan_utils.rs b/lightning/src/ln/chan_utils.rs index 13b39f6cabd..3af8ad5fc14 100644 --- a/lightning/src/ln/chan_utils.rs +++ b/lightning/src/ln/chan_utils.rs @@ -192,7 +192,7 @@ pub fn aggregated_htlc_timeout_input_output_pair_weight( } /// Describes the type of HTLC claim as determined by analyzing the witness. -#[derive(PartialEq, Eq)] +#[derive(PartialEq, Eq, Debug)] pub enum HTLCClaim { /// Claims an offered output on a commitment transaction through the timeout path. OfferedTimeout, From 9d6bdc7ad97fe600a6f2def9b2231ea98eb851e8 Mon Sep 17 00:00:00 2001 From: Matt Corallo Date: Tue, 4 Aug 2026 23:22:16 +0000 Subject: [PATCH 094/135] Add release notes for LDK 0.2.5 --- CHANGELOG.md | 35 +++++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 564ad4768de..8be785f004a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,38 @@ +# 0.2.5 - Aug 4, 2026 - "The MegaScan Project" + +## Bug Fixes + * LDK will no longer generate compact blinded paths with introduction nodes + defined by channel references for channels which are closed. This ensures + compact blinded paths built by LDK are reachable (#4828). + +## Security +0.2.5 fixes an on-chain funds-theft vulnerability for nodes forwarding HTLCs +which accept channels from untrusted nodes and several denial of service +vulnerabilities when forwarding HTLCs, accepting HTLCs which merit a response, +or in rare cases after a reorg when claiming HTLCs on chain. + * When processing an un-revoked counterparty's HTLC claim, ChannelMonitor will + no longer confuse two HTLCs with equivalent `payment_hash` and amounts, + leading to incorrect HTLC resolution (#4854). + * LDK will no longer panic when an onion message contains an invalid reply path + with the local node as the listed introduction point (#4850). + * LDK will no longer panic attempting to send messages which are too large to + fit in the lightning protocol's framing, including in cases where an HTLC + failure contains an oversized failure onion (in which case it will instead + decline to include attribution data) or when splicing with a transaction + which is too large (#4845, #4852). + * LDK's limit of 50 peers with unfunded channels is now properly enforced when + peers flood us with `open_channel` messages (#4851). + * LDK will no longer panic in rare cases when multiple pending HTLCs which + expire at different heights are being claimed on-chain and a reorg occurs + which changes whether we wish to batch our claim (#4849). + * `UntrustedString` and `PrintableString` now filter line- and paragraph-break + characters as well as control characters, providing additional robustness + especially for log parsing (#4848). + +Thanks to Project Loupe and Kyle W. Santiago for reporting security issues fixed +in this release. + + # 0.2.4 - Jun 25, 2026 - "Release the CI" ## Bug Fixes From 1fdacf8e0cb219f0cc17c56c95e17e75a1d0bfc2 Mon Sep 17 00:00:00 2001 From: Matt Corallo Date: Wed, 5 Aug 2026 01:16:43 +0000 Subject: [PATCH 095/135] Bump `lightning-types` to 0.3.5 and `lightning` to 0.2.5 --- lightning-types/Cargo.toml | 2 +- lightning/Cargo.toml | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/lightning-types/Cargo.toml b/lightning-types/Cargo.toml index 588eb151742..0f3aab0f356 100644 --- a/lightning-types/Cargo.toml +++ b/lightning-types/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "lightning-types" -version = "0.3.2" +version = "0.3.5" authors = ["Matt Corallo"] license = "MIT OR Apache-2.0" repository = "https://github.com/lightningdevkit/rust-lightning/" diff --git a/lightning/Cargo.toml b/lightning/Cargo.toml index 779a494df48..2558cd9c68c 100644 --- a/lightning/Cargo.toml +++ b/lightning/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "lightning" -version = "0.2.4" +version = "0.2.5" authors = ["Matt Corallo"] license = "MIT OR Apache-2.0" repository = "https://github.com/lightningdevkit/rust-lightning/" @@ -33,7 +33,7 @@ grind_signatures = [] default = ["std", "grind_signatures"] [dependencies] -lightning-types = { version = "0.3.0", path = "../lightning-types", default-features = false } +lightning-types = { version = "0.3.5", path = "../lightning-types", default-features = false } lightning-invoice = { version = "0.34.0", path = "../lightning-invoice", default-features = false } lightning-macros = { version = "0.2.1", path = "../lightning-macros" } From 2a37e2de7a82268fafd033d4d156fa4349bdd607 Mon Sep 17 00:00:00 2001 From: Matt Corallo Date: Wed, 5 Aug 2026 02:12:31 +0000 Subject: [PATCH 096/135] 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 `, which installs the toolchain if needed. Backport of c897a448be3a4c0fd388082696ba89c32a17397b Co-Authored-By: Claude --- .github/workflows/build.yml | 20 ++++++++++---------- .github/workflows/check_commits.yml | 2 +- .github/workflows/semver.yml | 2 +- 3 files changed, 12 insertions(+), 12 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index c3c61fb1a34..c7c3e6e5748 100644 --- a/.github/workflows/build.yml +++ b/.github/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 @@ -45,7 +45,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-latest' shell: bash @@ -78,7 +78,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: Set RUSTFLAGS to deny warnings if: "matrix.toolchain == '1.75.0'" run: echo "RUSTFLAGS=-D warnings" >> "$GITHUB_ENV" @@ -120,7 +120,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 @@ -150,7 +150,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 @@ -216,7 +216,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 @@ -247,7 +247,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 @@ -260,7 +260,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: Pin the syn and regex dependencies run: | cd fuzz && cargo update -p regex --precise "1.9.6" && cargo update -p syn --precise "2.0.106" && cargo update -p quote --precise "1.0.41" @@ -292,7 +292,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 @@ -309,7 +309,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 diff --git a/.github/workflows/check_commits.yml b/.github/workflows/check_commits.yml index 2920318858f..cd1fba023ec 100644 --- a/.github/workflows/check_commits.yml +++ b/.github/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://github.com/lightningdevkit/rust-lightning diff --git a/.github/workflows/semver.yml b/.github/workflows/semver.yml index 03017e19320..6dab209a842 100644 --- a/.github/workflows/semver.yml +++ b/.github/workflows/semver.yml @@ -15,7 +15,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 rustup override set stable - name: Check SemVer with default features uses: obi1kenobi/cargo-semver-checks-action@v2 From 6dfc7c66f39ad4d78659aee2de3905b98e112451 Mon Sep 17 00:00:00 2001 From: Matt Corallo Date: Wed, 5 Aug 2026 02:18:26 +0000 Subject: [PATCH 097/135] Use semver-checks by `cargo install` rather than GH actions dep This should allow semver-checks to run properly on forgejo --- .github/workflows/semver.yml | 52 ++++++++++-------------------------- 1 file changed, 14 insertions(+), 38 deletions(-) diff --git a/.github/workflows/semver.yml b/.github/workflows/semver.yml index 6dab209a842..e253be3cce3 100644 --- a/.github/workflows/semver.yml +++ b/.github/workflows/semver.yml @@ -9,48 +9,24 @@ on: jobs: semver-checks: - runs-on: ubuntu-latest + runs-on: debian-trixie steps: - name: Checkout source code uses: actions/checkout@v4 + with: + fetch-depth: 0 - name: Install Rust stable toolchain run: | rustup default stable rustup override set stable - - name: Check SemVer with default features - uses: obi1kenobi/cargo-semver-checks-action@v2 - with: - feature-group: default-features - - name: Check SemVer *without* default features - uses: obi1kenobi/cargo-semver-checks-action@v2 - with: - feature-group: only-explicit-features - - name: Check lightning-background-processor SemVer - uses: obi1kenobi/cargo-semver-checks-action@v2 - with: - package: lightning-background-processor - feature-group: only-explicit-features - - name: Check lightning-block-sync SemVer - uses: obi1kenobi/cargo-semver-checks-action@v2 - with: - package: lightning-block-sync - feature-group: only-explicit-features - features: rpc-client,rest-client - - name: Check lightning-transaction-sync electrum SemVer - uses: obi1kenobi/cargo-semver-checks-action@v2 - with: - manifest-path: lightning-transaction-sync/Cargo.toml - feature-group: only-explicit-features - features: electrum - - name: Check lightning-transaction-sync esplora-blocking SemVer - uses: obi1kenobi/cargo-semver-checks-action@v2 - with: - manifest-path: lightning-transaction-sync/Cargo.toml - feature-group: only-explicit-features - features: esplora-blocking - - name: Check lightning-transaction-sync esplora-async SemVer - uses: obi1kenobi/cargo-semver-checks-action@v2 - with: - manifest-path: lightning-transaction-sync/Cargo.toml - feature-group: only-explicit-features - features: esplora-async + - 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 + - name: Check lightning-transaction-sync SemVer + run: | + cd lightning-transaction-sync + cargo semver-checks + cargo semver-checks --only-explicit-features From 376eb6c712995f16a5282c586c1ccb64d93d11d7 Mon Sep 17 00:00:00 2001 From: Matt Corallo Date: Wed, 5 Aug 2026 02:27:22 +0000 Subject: [PATCH 098/135] Update MSRV compatibility pins in CI Co-Authored-By: Claude --- .github/workflows/build.yml | 4 ++-- ci/ci-tests.sh | 5 +++++ ci/ci-tx-sync-tests.sh | 10 ++++++++++ 3 files changed, 17 insertions(+), 2 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index c7c3e6e5748..431bfb0240c 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -263,14 +263,14 @@ jobs: rustup default ${{ env.TOOLCHAIN }} - name: Pin the syn and regex dependencies run: | - cd fuzz && cargo update -p regex --precise "1.9.6" && cargo update -p syn --precise "2.0.106" && cargo update -p quote --precise "1.0.41" + cd fuzz && cargo update -p regex --precise "1.9.6" && cargo update -p serde --precise "1.0.228" && cargo update -p syn --precise "2.0.106" && cargo update -p quote --precise "1.0.41" cargo update -p proc-macro2 --precise "1.0.103" --verbose && cargo update -p serde_json --precise "1.0.145" --verbose cargo update -p itoa --precise "1.0.15" --verbose && cargo update -p ryu --precise "1.0.20" --verbose cargo update -p libc --precise 0.2.183 cargo update -p unicode-ident --precise 1.0.22 cargo update -p honggfuzz --precise 0.5.59 cargo update -p semver --precise 1.0.27 - cd write-seeds && cargo update -p regex --precise "1.9.6" && cargo update -p syn --precise "2.0.106" && cargo update -p quote --precise "1.0.41" + cd write-seeds && cargo update -p regex --precise "1.9.6" && cargo update -p serde --precise "1.0.228" && cargo update -p syn --precise "2.0.106" && cargo update -p quote --precise "1.0.41" cargo update -p proc-macro2 --precise "1.0.103" --verbose && cargo update -p serde_json --precise "1.0.145" --verbose cargo update -p itoa --precise "1.0.15" --verbose && cargo update -p ryu --precise "1.0.20" --verbose cargo update -p libc --precise 0.2.183 diff --git a/ci/ci-tests.sh b/ci/ci-tests.sh index fc2d61e3f1e..5647267886e 100755 --- a/ci/ci-tests.sh +++ b/ci/ci-tests.sh @@ -11,6 +11,9 @@ function PIN_RELEASE_DEPS { # Starting with version 1.39.0, the `tokio` crate has an MSRV of rustc 1.70.0 [ "$RUSTC_MINOR_VERSION" -lt 70 ] && cargo update -p tokio --precise "1.38.1" --verbose + # Starting with version 1.0.229, the `serde_derive` crate has an MSRV of rustc 1.71. + [ "$RUSTC_MINOR_VERSION" -lt 71 ] && cargo update -p serde --precise "1.0.228" --verbose + # syn 2.0.107 requires rustc 1.68.0 [ "$RUSTC_MINOR_VERSION" -lt 68 ] && cargo update -p syn --precise "2.0.106" --verbose @@ -81,6 +84,7 @@ cargo test --verbose --color always echo -e "\n\nTesting upgrade from prior versions of LDK" pushd lightning-tests [ "$RUSTC_MINOR_VERSION" -lt 65 ] && cargo update -p regex --precise "1.9.6" --verbose +[ "$RUSTC_MINOR_VERSION" -lt 71 ] && cargo update -p serde --precise "1.0.228" --verbose [ "$RUSTC_MINOR_VERSION" -lt 68 ] && cargo update -p syn --precise "2.0.106" --verbose [ "$RUSTC_MINOR_VERSION" -lt 68 ] && cargo update -p quote --precise "1.0.41" --verbose [ "$RUSTC_MINOR_VERSION" -lt 68 ] && cargo update -p proc-macro2 --precise "1.0.103" --verbose @@ -156,6 +160,7 @@ cargo test -p lightning-invoice --verbose --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 +[ "$RUSTC_MINOR_VERSION" -lt 71 ] && cargo update -p serde --precise "1.0.228" --verbose [ "$RUSTC_MINOR_VERSION" -lt 68 ] && cargo update -p syn --precise "2.0.106" --verbose [ "$RUSTC_MINOR_VERSION" -lt 68 ] && cargo update -p quote --precise "1.0.41" --verbose [ "$RUSTC_MINOR_VERSION" -lt 68 ] && cargo update -p proc-macro2 --precise "1.0.103" --verbose diff --git a/ci/ci-tx-sync-tests.sh b/ci/ci-tx-sync-tests.sh index a67f0bc88de..f5199194d64 100755 --- a/ci/ci-tx-sync-tests.sh +++ b/ci/ci-tx-sync-tests.sh @@ -26,6 +26,16 @@ PIN_RELEASE_DEPS # pin the release dependencies # Starting with version 0.27.8, the `hyper-rustls` crate has an MSRV of rustc 1.85. [ "$RUSTC_MINOR_VERSION" -lt 85 ] && cargo update -p hyper-rustls --precise "0.27.7" --verbose +# Starting with version 0.1.35, the `jobserver` crate has an MSRV of rustc 1.85. +[ "$RUSTC_MINOR_VERSION" -lt 85 ] && cargo update -p jobserver --precise "0.1.34" --verbose + +# Starting with version 0.11.16, the `quinn-proto` crate depends on `rand` 0.10, which pulls in +# `chacha20` 0.10, `cpufeatures` 0.3, `rand_core` 0.10, `rand_pcg` 0.10 and `getrandom` 0.4, all of +# which are edition 2024 and thus not even parsable by our MSRV's cargo. `quinn` only reaches us as +# an unused optional dependency of `reqwest`, so it is never built, but cargo still insists on +# reading the manifests. +[ "$RUSTC_MINOR_VERSION" -lt 85 ] && cargo update -p quinn-proto --precise "0.11.15" --verbose + export RUST_BACKTRACE=1 echo -e "\n\nChecking Transaction Sync Clients with features." From 98ff704bfc303514549f11b32a617e696a023f3e Mon Sep 17 00:00:00 2001 From: Ayla Greystone Date: Thu, 19 Jun 2025 15:51:17 -0400 Subject: [PATCH 099/135] LSPS4: Custom MDK LSPS Liquidity Protocol --- lightning-background-processor/src/lib.rs | 2 + lightning-liquidity/src/events/mod.rs | 17 + lightning-liquidity/src/lib.rs | 1 + lightning-liquidity/src/lsps0/msgs.rs | 1 + lightning-liquidity/src/lsps0/ser.rs | 56 +++ lightning-liquidity/src/lsps4/client.rs | 202 ++++++++++ lightning-liquidity/src/lsps4/event.rs | 60 +++ lightning-liquidity/src/lsps4/htlc_store.rs | 224 +++++++++++ lightning-liquidity/src/lsps4/mod.rs | 18 + lightning-liquidity/src/lsps4/msgs.rs | 98 +++++ lightning-liquidity/src/lsps4/scid_store.rs | 179 +++++++++ lightning-liquidity/src/lsps4/service.rs | 408 ++++++++++++++++++++ lightning-liquidity/src/lsps4/utils.rs | 46 +++ lightning-liquidity/src/manager.rs | 196 ++++++++-- 14 files changed, 1486 insertions(+), 22 deletions(-) create mode 100644 lightning-liquidity/src/lsps4/client.rs create mode 100644 lightning-liquidity/src/lsps4/event.rs create mode 100644 lightning-liquidity/src/lsps4/htlc_store.rs create mode 100644 lightning-liquidity/src/lsps4/mod.rs create mode 100644 lightning-liquidity/src/lsps4/msgs.rs create mode 100644 lightning-liquidity/src/lsps4/scid_store.rs create mode 100644 lightning-liquidity/src/lsps4/service.rs create mode 100644 lightning-liquidity/src/lsps4/utils.rs diff --git a/lightning-background-processor/src/lib.rs b/lightning-background-processor/src/lib.rs index cdf1b2e5aa3..abe8fa751f7 100644 --- a/lightning-background-processor/src/lib.rs +++ b/lightning-background-processor/src/lib.rs @@ -465,6 +465,8 @@ pub const NO_LIQUIDITY_MANAGER_SYNC: Option< + Send + Sync, T = &(dyn BroadcasterInterface + Send + Sync), + Logger = dyn lightning::util::logger::Logger + Send + Sync, + L = &(dyn lightning::util::logger::Logger + Send + Sync), > + Send + Sync, >, diff --git a/lightning-liquidity/src/events/mod.rs b/lightning-liquidity/src/events/mod.rs index c39b8b9fd59..fb3d8cefc4d 100644 --- a/lightning-liquidity/src/events/mod.rs +++ b/lightning-liquidity/src/events/mod.rs @@ -23,6 +23,7 @@ pub(crate) use event_queue::{EventQueue, EventQueueDeserWrapper}; use crate::lsps0; use crate::lsps1; use crate::lsps2; +use crate::lsps4; use crate::lsps5; /// An event which you should probably take some action in response to. @@ -39,6 +40,10 @@ pub enum LiquidityEvent { LSPS2Client(lsps2::event::LSPS2ClientEvent), /// An LSPS2 (JIT Channel) server event. LSPS2Service(lsps2::event::LSPS2ServiceEvent), + /// An LSPS4 (Liquidity) client event. + LSPS4Client(lsps4::event::LSPS4ClientEvent), + /// An LSPS4 (Liquidity) server event. + LSPS4Service(lsps4::event::LSPS4ServiceEvent), /// An LSPS5 (Webhook) client event. LSPS5Client(lsps5::event::LSPS5ClientEvent), /// An LSPS5 (Webhook) server event. @@ -76,6 +81,18 @@ impl From for LiquidityEvent { } } +impl From for LiquidityEvent { + fn from(event: lsps4::event::LSPS4ClientEvent) -> Self { + Self::LSPS4Client(event) + } +} + +impl From for LiquidityEvent { + fn from(event: lsps4::event::LSPS4ServiceEvent) -> Self { + Self::LSPS4Service(event) + } +} + impl From for LiquidityEvent { fn from(event: lsps5::event::LSPS5ClientEvent) -> Self { Self::LSPS5Client(event) diff --git a/lightning-liquidity/src/lib.rs b/lightning-liquidity/src/lib.rs index 2f1d5bd01e7..b8aa448977b 100644 --- a/lightning-liquidity/src/lib.rs +++ b/lightning-liquidity/src/lib.rs @@ -62,6 +62,7 @@ pub mod events; pub mod lsps0; pub mod lsps1; pub mod lsps2; +pub mod lsps4; pub mod lsps5; mod manager; pub mod message_queue; diff --git a/lightning-liquidity/src/lsps0/msgs.rs b/lightning-liquidity/src/lsps0/msgs.rs index 6fb885659b5..a3e028f0fce 100644 --- a/lightning-liquidity/src/lsps0/msgs.rs +++ b/lightning-liquidity/src/lsps0/msgs.rs @@ -83,6 +83,7 @@ impl TryFrom for LSPS0Message { LSPSMessage::LSPS0(message) => Ok(message), LSPSMessage::LSPS1(_) => Err(()), LSPSMessage::LSPS2(_) => Err(()), + LSPSMessage::LSPS4(_) => Err(()), LSPSMessage::LSPS5(_) => Err(()), } } diff --git a/lightning-liquidity/src/lsps0/ser.rs b/lightning-liquidity/src/lsps0/ser.rs index 2f62ae24cc8..f3ea8f2ee4f 100644 --- a/lightning-liquidity/src/lsps0/ser.rs +++ b/lightning-liquidity/src/lsps0/ser.rs @@ -21,6 +21,9 @@ use crate::lsps1::msgs::{ use crate::lsps2::msgs::{ LSPS2Message, LSPS2Request, LSPS2Response, LSPS2_BUY_METHOD_NAME, LSPS2_GET_INFO_METHOD_NAME, }; +use crate::lsps4::msgs::{ + LSPS4Message, LSPS4Request, LSPS4Response, LSPS4_REGISTER_NODE_METHOD_NAME, +}; use crate::lsps5::msgs::{ LSPS5Message, LSPS5Request, LSPS5Response, LSPS5_LIST_WEBHOOKS_METHOD_NAME, LSPS5_REMOVE_WEBHOOK_METHOD_NAME, LSPS5_SET_WEBHOOK_METHOD_NAME, @@ -66,6 +69,7 @@ pub(crate) enum LSPSMethod { LSPS1CreateOrder, LSPS2GetInfo, LSPS2Buy, + LSPS4RegisterNode, LSPS5SetWebhook, LSPS5ListWebhooks, LSPS5RemoveWebhook, @@ -80,6 +84,7 @@ impl LSPSMethod { Self::LSPS1GetOrder => LSPS1_GET_ORDER_METHOD_NAME, Self::LSPS2GetInfo => LSPS2_GET_INFO_METHOD_NAME, Self::LSPS2Buy => LSPS2_BUY_METHOD_NAME, + Self::LSPS4RegisterNode => LSPS4_REGISTER_NODE_METHOD_NAME, Self::LSPS5SetWebhook => LSPS5_SET_WEBHOOK_METHOD_NAME, Self::LSPS5ListWebhooks => LSPS5_LIST_WEBHOOKS_METHOD_NAME, Self::LSPS5RemoveWebhook => LSPS5_REMOVE_WEBHOOK_METHOD_NAME, @@ -97,6 +102,7 @@ impl FromStr for LSPSMethod { LSPS1_GET_ORDER_METHOD_NAME => Ok(Self::LSPS1GetOrder), LSPS2_GET_INFO_METHOD_NAME => Ok(Self::LSPS2GetInfo), LSPS2_BUY_METHOD_NAME => Ok(Self::LSPS2Buy), + LSPS4_REGISTER_NODE_METHOD_NAME => Ok(Self::LSPS4RegisterNode), LSPS5_SET_WEBHOOK_METHOD_NAME => Ok(Self::LSPS5SetWebhook), LSPS5_LIST_WEBHOOKS_METHOD_NAME => Ok(Self::LSPS5ListWebhooks), LSPS5_REMOVE_WEBHOOK_METHOD_NAME => Ok(Self::LSPS5RemoveWebhook), @@ -132,6 +138,14 @@ impl From<&LSPS2Request> for LSPSMethod { } } +impl From<&LSPS4Request> for LSPSMethod { + fn from(value: &LSPS4Request) -> Self { + match value { + LSPS4Request::RegisterNode(_) => Self::LSPS4RegisterNode, + } + } +} + impl From<&LSPS5Request> for LSPSMethod { fn from(value: &LSPS5Request) -> Self { match value { @@ -339,6 +353,8 @@ pub enum LSPSMessage { LSPS1(LSPS1Message), /// An LSPS2 message. LSPS2(LSPS2Message), + /// An LSPS4 message. + LSPS4(LSPS4Message), /// An LSPS5 message. LSPS5(LSPS5Message), } @@ -368,6 +384,9 @@ impl LSPSMessage { LSPSMessage::LSPS2(LSPS2Message::Request(request_id, request)) => { Some((LSPSRequestId(request_id.0.clone()), request.into())) }, + LSPSMessage::LSPS4(LSPS4Message::Request(request_id, request)) => { + Some((LSPSRequestId(request_id.0.clone()), request.into())) + }, LSPSMessage::LSPS5(LSPS5Message::Request(request_id, request)) => { Some((LSPSRequestId(request_id.0.clone()), request.into())) }, @@ -483,6 +502,26 @@ impl Serialize for LSPSMessage { }, } }, + LSPSMessage::LSPS4(LSPS4Message::Request(request_id, request)) => { + jsonrpc_object.serialize_field(JSONRPC_ID_FIELD_KEY, &request_id.0)?; + jsonrpc_object + .serialize_field(JSONRPC_METHOD_FIELD_KEY, &LSPSMethod::from(request))?; + + match request { + LSPS4Request::RegisterNode(params) => { + jsonrpc_object.serialize_field(JSONRPC_PARAMS_FIELD_KEY, params)? + }, + } + }, + LSPSMessage::LSPS4(LSPS4Message::Response(request_id, response)) => { + jsonrpc_object.serialize_field(JSONRPC_ID_FIELD_KEY, &request_id.0)?; + + match response { + LSPS4Response::RegisterNode(result) => { + jsonrpc_object.serialize_field(JSONRPC_RESULT_FIELD_KEY, result)? + }, + } + }, LSPSMessage::Invalid(error) => { jsonrpc_object.serialize_field(JSONRPC_ID_FIELD_KEY, &serde_json::Value::Null)?; jsonrpc_object.serialize_field(JSONRPC_ERROR_FIELD_KEY, &error)?; @@ -638,6 +677,11 @@ impl<'de, 'a> Visitor<'de> for LSPSMessageVisitor<'a> { .map_err(de::Error::custom)?; Ok(LSPSMessage::LSPS2(LSPS2Message::Request(id, LSPS2Request::Buy(request)))) }, + LSPSMethod::LSPS4RegisterNode => { + let request = serde_json::from_value(params.unwrap_or(json!({}))) + .map_err(de::Error::custom)?; + Ok(LSPSMessage::LSPS4(LSPS4Message::Request(id, LSPS4Request::RegisterNode(request)))) + }, LSPSMethod::LSPS5SetWebhook => { let request = serde_json::from_value(params.unwrap_or(json!({}))) .map_err(de::Error::custom)?; @@ -767,6 +811,18 @@ impl<'de, 'a> Visitor<'de> for LSPSMessageVisitor<'a> { Err(de::Error::custom("Received invalid JSON-RPC object: one of method, result, or error required")) } }, + LSPSMethod::LSPS4RegisterNode => { + if let Some(result) = result { + let response = + serde_json::from_value(result).map_err(de::Error::custom)?; + Ok(LSPSMessage::LSPS4(LSPS4Message::Response( + id, + LSPS4Response::RegisterNode(response), + ))) + } else { + Err(de::Error::custom("Received invalid JSON-RPC object: one of method, result, or error required")) + } + }, LSPSMethod::LSPS5SetWebhook => { if let Some(error) = error { Ok(LSPSMessage::LSPS5(LSPS5Message::Response( diff --git a/lightning-liquidity/src/lsps4/client.rs b/lightning-liquidity/src/lsps4/client.rs new file mode 100644 index 00000000000..f8e1e749896 --- /dev/null +++ b/lightning-liquidity/src/lsps4/client.rs @@ -0,0 +1,202 @@ +// 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 the main LSPS4 client object, [`LSPS4ClientHandler`]. + +use crate::events::{LiquidityEvent, EventQueue}; +use crate::lsps0::ser::{LSPSProtocolMessageHandler, LSPSRequestId, LSPSResponseError}; +use crate::lsps4::event::LSPS4ClientEvent; +use crate::message_queue::MessageQueue; +use crate::prelude::{new_hash_map, new_hash_set, HashMap, HashSet}; +use crate::sync::{Arc, Mutex, RwLock}; + +use lightning::ln::msgs::{ErrorAction, LightningError}; +use lightning::sign::EntropySource; +use lightning::util::errors::APIError; +use lightning::util::logger::Level; +use lightning::util::persist::KVStore; + +use bitcoin::secp256k1::PublicKey; + +use std::string::String; + + +use core::default::Default; +use core::ops::Deref; + +use crate::lsps4::msgs::{ + RegisterNodeRequest, RegisterNodeResponse, LSPS4Message, LSPS4Request, + LSPS4Response, +}; + +/// Client-side configuration options for JIT channels. +#[derive(Clone, Debug, Copy, Default)] +pub struct LSPS4ClientConfig {} + +struct PeerState { + pending_register_node_requests: HashSet, +} + +impl PeerState { + fn new() -> Self { + let pending_register_node_requests = new_hash_set(); + Self { pending_register_node_requests } + } +} + +/// The main object allowing to send and receive LSPS4 messages. +pub struct LSPS4ClientHandler +where + ES::Target: EntropySource, + K::Target: KVStore, +{ + entropy_source: ES, + pending_messages: Arc, + pending_events: Arc>, + per_peer_state: RwLock>>, + _config: LSPS4ClientConfig, +} + +impl LSPS4ClientHandler +where + ES::Target: EntropySource, + K::Target: KVStore, +{ + /// Constructs an `LSPS4ClientHandler`. + pub(crate) fn new( + entropy_source: ES, pending_messages: Arc, pending_events: Arc>, + _config: LSPS4ClientConfig, + ) -> Self { + Self { + entropy_source, + pending_messages, + pending_events, + per_peer_state: RwLock::new(new_hash_map()), + _config, + } + } + + /// Requests the LSP to register the node. + /// + /// The user will receive the LSP's response via an [`InvoiceParametersReady`] event. + /// + /// [`InvoiceParametersReady`]: crate::lsps4::event::LSPS4ClientEvent::InvoiceParametersReady + pub fn register_node( + &self, counterparty_node_id: PublicKey + ) -> Result { + let request_id = crate::utils::generate_request_id(&self.entropy_source); + + { + let mut outer_state_lock = self.per_peer_state.write().unwrap(); + let inner_state_lock = outer_state_lock + .entry(counterparty_node_id) + .or_insert(Mutex::new(PeerState::new())); + let mut peer_state_lock = inner_state_lock.lock().unwrap(); + + if !peer_state_lock + .pending_register_node_requests + .insert(request_id.clone()) + { + return Err(APIError::APIMisuseError { + err: "Failed due to duplicate request_id. This should never happen!" + .to_string(), + }); + } + } + + let request = LSPS4Request::RegisterNode(RegisterNodeRequest {}); + let msg = LSPS4Message::Request(request_id.clone(), request).into(); + let mut message_queue_notifier = self.pending_messages.notifier(); + message_queue_notifier.enqueue(&counterparty_node_id, msg); + + Ok(request_id) + } + + + fn handle_register_node_response( + &self, request_id: LSPSRequestId, counterparty_node_id: &PublicKey, result: RegisterNodeResponse, + ) -> Result<(), LightningError> { + 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(); + + if !peer_state.pending_register_node_requests.remove(&request_id) { + return Err(LightningError { + err: format!( + "Received register_node response for an unknown request: {:?}", + request_id + ), + action: ErrorAction::IgnoreAndLog(Level::Info), + }); + } + + if let Ok(intercept_scid) = result.jit_channel_scid.to_scid() { + let mut event_queue_notifier = self.pending_events.notifier(); + event_queue_notifier.enqueue(LiquidityEvent::LSPS4Client( + LSPS4ClientEvent::InvoiceParametersReady { + request_id, + counterparty_node_id: *counterparty_node_id, + intercept_scid, + cltv_expiry_delta: result.lsp_cltv_expiry_delta, + }, + )); + } else { + return Err(LightningError { + err: format!( + "Received register_node response with an invalid intercept scid {:?}", + result.jit_channel_scid + ), + action: ErrorAction::IgnoreAndLog(Level::Info), + }); + } + }, + None => { + return Err(LightningError { + err: format!( + "Received register_node response from unknown peer: {:?}", + counterparty_node_id + ), + action: ErrorAction::IgnoreAndLog(Level::Info), + }); + }, + } + Ok(()) + } +} + +impl LSPSProtocolMessageHandler for LSPS4ClientHandler +where + ES::Target: EntropySource, + K::Target: KVStore, +{ + type ProtocolMessage = LSPS4Message; + const PROTOCOL_NUMBER: Option = Some(4); + + fn handle_message( + &self, message: Self::ProtocolMessage, counterparty_node_id: &PublicKey, + ) -> Result<(), LightningError> { + match message { + LSPS4Message::Response(request_id, response) => match response { + LSPS4Response::RegisterNode(result) => { + self.handle_register_node_response(request_id, counterparty_node_id, result) + }, + }, + _ => { + debug_assert!( + false, + "Client handler received LSPS4 request message. This should never happen." + ); + Err(LightningError { err: format!("Client handler received LSPS4 request message from node {:?}. This should never happen.", counterparty_node_id), action: ErrorAction::IgnoreAndLog(Level::Info)}) + }, + } + } +} + +#[cfg(test)] +mod tests {} diff --git a/lightning-liquidity/src/lsps4/event.rs b/lightning-liquidity/src/lsps4/event.rs new file mode 100644 index 00000000000..0ac24bc69e6 --- /dev/null +++ b/lightning-liquidity/src/lsps4/event.rs @@ -0,0 +1,60 @@ +// 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 LSPS4 event types + +use crate::lsps0::ser::LSPSRequestId; +use std::string::String; +use std::vec::Vec; + + +use bitcoin::secp256k1::PublicKey; + +/// An event which an LSPS4 client should take some action in response to. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum LSPS4ClientEvent { + /// Provides the necessary information to generate a payable invoice that then may be given to + /// the payer. + /// The LSP will ensure that invoices with this intercept scid are always payable by opening + /// channels as necessary. + InvoiceParametersReady { + /// The identifier of the issued LSPS4 `buy` request, as returned by + /// [`LSPS4ClientHandler::select_opening_params`]. + /// + /// This can be used to track which request this event corresponds to. + /// + /// [`LSPS4ClientHandler::select_opening_params`]: crate::lsps4::client::LSPS4ClientHandler::select_opening_params + request_id: LSPSRequestId, + /// The node id of the LSP. + counterparty_node_id: PublicKey, + /// The intercept short channel id to use in the route hint. + intercept_scid: u64, + /// The `cltv_expiry_delta` to use in the route hint. + cltv_expiry_delta: u32, + }, +} + +/// An event which an LSPS4 server should take some action in response to. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum LSPS4ServiceEvent { + /// Send a webhook to the client to notify them they have pending payments. + SendWebhook { + /// The node id of the counterparty. + counterparty_node_id: PublicKey, + }, + /// You should open a channel using [`ChannelManager::create_channel`]. + /// + /// [`ChannelManager::create_channel`]: lightning::ln::channelmanager::ChannelManager::create_channel + OpenChannel { + /// The node to open channel with. + their_network_key: PublicKey, + /// The amount we need to forward to the client after opening. + amt_to_forward_msat: u64, + }, +} diff --git a/lightning-liquidity/src/lsps4/htlc_store.rs b/lightning-liquidity/src/lsps4/htlc_store.rs new file mode 100644 index 00000000000..434620e94af --- /dev/null +++ b/lightning-liquidity/src/lsps4/htlc_store.rs @@ -0,0 +1,224 @@ +// 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. + +use lightning::util::ser::{Readable, Writeable}; +use lightning::{impl_writeable_tlv_based, log_error}; +use lightning::ln::channelmanager::InterceptId; +use lightning::ln::types::ChannelId; + +use lightning::util::logger::Logger; +use lightning::util::persist::KVStoreSync; +use lightning_types::payment::PaymentHash; + +use bitcoin::secp256k1::PublicKey; + +use lightning::io::{self, Cursor}; + +use std::collections::HashMap; +use std::ops::Deref; +use std::sync::Mutex; +use std::time::{SystemTime, UNIX_EPOCH}; + +use crate::lsps4::utils; + +/// The Intercepted HTLC store information will be persisted under this key. +pub(crate) const INTERCEPTED_HTLC_STORE_PERSISTENCE_PRIMARY_NAMESPACE: &str = "intercepted_htlcs"; +pub(crate) const INTERCEPTED_HTLC_STORE_PERSISTENCE_SECONDARY_NAMESPACE: &str = ""; + + +/// Represents an intercepted HTLC that is stored in the data store +#[derive(Clone, Debug, PartialEq)] +pub struct InterceptedHtlc { + /// Unique identifier for this intercepted HTLC + id: InterceptId, + /// The short channel ID of the next hop that the HTLC was originally intended for + requested_next_hop_scid: u64, + /// The amount in millisatoshis that should be forwarded to the next hop + expected_outbound_amount_msat: u64, + /// The payment hash of the HTLC + payment_hash: PaymentHash, + /// Timestamp (seconds since UNIX_EPOCH) when the HTLC was intercepted + intercepted_at: u64, + /// The next node ID that this HTLC should be forwarded to + next_node_id: PublicKey, +} + +impl InterceptedHtlc { + /// Create a new intercepted HTLC + pub fn new( + intercept_id: InterceptId, requested_next_hop_scid: u64, + expected_outbound_amount_msat: u64, payment_hash: PaymentHash, next_node_id: PublicKey, + ) -> Self { + let now = SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default().as_secs(); + Self { + id: intercept_id, + requested_next_hop_scid, + expected_outbound_amount_msat, + payment_hash, + intercepted_at: now, + next_node_id, + } + } + + pub fn id(&self) -> InterceptId { + self.id + } + + pub fn requested_next_hop_scid(&self) -> u64 { + self.requested_next_hop_scid + } + + pub fn expected_outbound_amount_msat(&self) -> u64 { + self.expected_outbound_amount_msat + } + + pub fn payment_hash(&self) -> PaymentHash { + self.payment_hash + } + + pub fn intercepted_at(&self) -> u64 { + self.intercepted_at + } + + pub fn next_node_id(&self) -> PublicKey { + self.next_node_id + } +} + +impl_writeable_tlv_based!(InterceptedHtlc, { + (0, id, required), + (2, requested_next_hop_scid, required), + (4, expected_outbound_amount_msat, required), + (6, payment_hash, required), + (10, intercepted_at, required), + (12, next_node_id, required), +}); + +pub struct HTLCStore +where L::Target: Logger, KV::Target: KVStoreSync { + htlcs: Mutex>, + kv_store: KV, + logger: L +} + +impl HTLCStore +where L::Target: Logger, KV::Target: KVStoreSync { + pub(crate) fn new( + kv_store: KV, logger: L, + ) -> Result { + let mut htlcs = Vec::new(); + + for stored_key in kv_store.list( + INTERCEPTED_HTLC_STORE_PERSISTENCE_PRIMARY_NAMESPACE, + INTERCEPTED_HTLC_STORE_PERSISTENCE_SECONDARY_NAMESPACE, + )? { + let mut reader = Cursor::new(kv_store.read( + INTERCEPTED_HTLC_STORE_PERSISTENCE_PRIMARY_NAMESPACE, + INTERCEPTED_HTLC_STORE_PERSISTENCE_SECONDARY_NAMESPACE, + &stored_key, + )?); + let htlc = InterceptedHtlc::read(&mut reader).map_err(|e| { + log_error!(logger, "Failed to deserialize InterceptedHtlc: {}", e); + io::Error::new( + io::ErrorKind::InvalidData, + "Failed to deserialize InterceptedHtlc", + ) + })?; + htlcs.push(htlc); + } + + let htlcs = + Mutex::new(HashMap::from_iter(htlcs.into_iter().map(|obj| (obj.id(), obj)))); + + Ok(Self { htlcs, kv_store, logger }) + } + + pub(crate) fn insert(&self, htlc: InterceptedHtlc) -> Result { + let mut locked_htlcs = self.htlcs.lock().unwrap(); + + if locked_htlcs.contains_key(&htlc.id()) { + return Ok(false); + } + + self.persist(&htlc)?; + let updated = locked_htlcs.insert(htlc.id(), htlc).is_some(); + Ok(updated) + } + + pub(crate) fn remove(&self, id: &InterceptId) -> Result<(), io::Error> { + let removed = self.htlcs.lock().unwrap().remove(id).is_some(); + if removed { + let store_key = utils::to_string(&id.0); + self.kv_store + .remove(INTERCEPTED_HTLC_STORE_PERSISTENCE_PRIMARY_NAMESPACE, INTERCEPTED_HTLC_STORE_PERSISTENCE_SECONDARY_NAMESPACE, &store_key, false) + .map_err(|e| { + log_error!( + self.logger, + "Removing htlc with intercept id {} failed due to: {}", + store_key, + e + ); + e + })?; + } + Ok(()) + } + + pub(crate) fn get(&self, id: &InterceptId) -> Option { + self.htlcs.lock().unwrap().get(id).cloned() + } + + pub(crate) fn list_filter bool>(&self, f: F) -> Vec { + self.htlcs.lock().unwrap().values().filter(f).cloned().collect::>() + } + + /// Get all intercepted HTLCs for a specific node + pub fn get_htlcs_by_node_id(&self, node_id: &PublicKey) -> Vec { + let mut htlcs = self.list_filter(|htlc| htlc.next_node_id() == *node_id); + htlcs.sort_by_key(|htlc| htlc.expected_outbound_amount_msat()); + htlcs + } + + /// Get all intercepted HTLCs that are older than the specified threshold in seconds. + pub fn get_expired_htlcs(&self, now: u64, threshold_seconds: u64) -> Vec { + self.list_filter(|htlc| { + // Only include HTLCs that are older than the threshold + now.saturating_sub(htlc.intercepted_at()) > threshold_seconds + }) + } + + fn persist(&self, htlc: &InterceptedHtlc) -> Result<(), io::Error> { + let store_key = utils::to_string(&htlc.id().0); + let data = htlc.encode(); + self.kv_store + .write(INTERCEPTED_HTLC_STORE_PERSISTENCE_PRIMARY_NAMESPACE, INTERCEPTED_HTLC_STORE_PERSISTENCE_SECONDARY_NAMESPACE, &store_key, data) + .map_err(|e| { + log_error!( + self.logger, + "Write for key {} failed due to: {}", + store_key, + e + ); + e + })?; + Ok(()) + } + + pub fn add_intercepted_htlc( + &self, intercept_id: InterceptId, requested_next_hop_scid: u64, + expected_outbound_amount_msat: u64, payment_hash: PaymentHash, next_node_id: PublicKey, + ) -> Result { + let htlc = InterceptedHtlc::new( + intercept_id, + requested_next_hop_scid, + expected_outbound_amount_msat, + payment_hash, + next_node_id, + ); + self.insert(htlc) + } +} \ No newline at end of file diff --git a/lightning-liquidity/src/lsps4/mod.rs b/lightning-liquidity/src/lsps4/mod.rs new file mode 100644 index 00000000000..d5c4987d7f6 --- /dev/null +++ b/lightning-liquidity/src/lsps4/mod.rs @@ -0,0 +1,18 @@ +// 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. + +//! Implementation of LSPS4: JIT Channel Negotiation specification. + +pub mod client; +pub mod event; +pub mod msgs; +pub(crate) mod htlc_store; +pub(crate) mod scid_store; +pub mod service; +pub mod utils; diff --git a/lightning-liquidity/src/lsps4/msgs.rs b/lightning-liquidity/src/lsps4/msgs.rs new file mode 100644 index 00000000000..271787a793f --- /dev/null +++ b/lightning-liquidity/src/lsps4/msgs.rs @@ -0,0 +1,98 @@ +//! Message, request, and other primitive types used to implement LSPS4. + +use core::convert::TryFrom; + +use bitcoin::hashes::hmac::{Hmac, HmacEngine}; +use bitcoin::hashes::sha256::Hash as Sha256; +use bitcoin::hashes::{Hash, HashEngine}; +use chrono::Utc; +use serde::{Deserialize, Serialize}; + +use lightning::util::scid_utils; + +use crate::lsps0::ser::{ + string_amount, string_amount_option, LSPSMessage, LSPSRequestId, LSPSResponseError, +}; + +use std::string::String; +use std::vec::Vec; +use crate::utils; + +pub(crate) const LSPS4_REGISTER_NODE_METHOD_NAME: &str = "lsps4.register_node"; + +#[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize)] +/// A request made to an LSP to register a node. +pub struct RegisterNodeRequest {} + +/// A newtype that holds a `short_channel_id` in human readable format of BBBxTTTx000. +#[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize)] +pub struct InterceptScid(String); + +impl From for InterceptScid { + fn from(scid: u64) -> Self { + let block = scid_utils::block_from_scid(scid); + let tx_index = scid_utils::tx_index_from_scid(scid); + let vout = scid_utils::vout_from_scid(scid); + + Self(format!("{}x{}x{}", block, tx_index, vout)) + } +} + +impl InterceptScid { + /// Try to convert a [`InterceptScid`] into a u64 used by LDK. + pub fn to_scid(&self) -> Result { + utils::scid_from_human_readable_string(&self.0) + } +} + +/// A response to a [`RegisterNodeRequest`]. +/// +/// Includes information needed to construct an invoice. +#[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize)] +pub struct RegisterNodeResponse { + /// The intercept short channel id used by LSP to identify need to open channel. + pub jit_channel_scid: InterceptScid, + /// The locktime expiry delta the lsp requires. + pub lsp_cltv_expiry_delta: u32, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +/// An enum that captures all the valid JSON-RPC requests in the LSPS4 protocol. +pub enum LSPS4Request { + /// A request to register a node with an LSP. + RegisterNode(RegisterNodeRequest), +} + +#[derive(Clone, Debug, PartialEq, Eq)] +/// An enum that captures all the valid JSON-RPC responses in the LSPS4 protocol. +pub enum LSPS4Response { + /// A successful response to a [`LSPS4Request::RegisterNode`] request. + RegisterNode(RegisterNodeResponse), +} + +#[derive(Clone, Debug, PartialEq, Eq)] +/// An enum that captures all valid JSON-RPC messages in the LSPS4 protocol. +pub enum LSPS4Message { + /// An LSPS4 JSON-RPC request. + Request(LSPSRequestId, LSPS4Request), + /// An LSPS4 JSON-RPC response. + Response(LSPSRequestId, LSPS4Response), +} + +impl TryFrom for LSPS4Message { + type Error = (); + + fn try_from(message: LSPSMessage) -> Result { + if let LSPSMessage::LSPS4(message) = message { + return Ok(message); + } + + Err(()) + } +} + +impl From for LSPSMessage { + fn from(message: LSPS4Message) -> Self { + LSPSMessage::LSPS4(message) + } +} diff --git a/lightning-liquidity/src/lsps4/scid_store.rs b/lightning-liquidity/src/lsps4/scid_store.rs new file mode 100644 index 00000000000..d339fb7e5af --- /dev/null +++ b/lightning-liquidity/src/lsps4/scid_store.rs @@ -0,0 +1,179 @@ +// 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. + +use lightning::util::ser::{Readable, Writeable}; +use lightning::{impl_writeable_tlv_based, log_error}; +use lightning::ln::channelmanager::InterceptId; +use lightning::ln::types::ChannelId; + +use lightning::util::logger::Logger; +use lightning::util::persist::KVStoreSync; +use lightning_types::payment::PaymentHash; + +use bitcoin::secp256k1::PublicKey; + +use lightning::io::{self, Cursor}; + +use std::collections::HashMap; +use std::ops::Deref; +use std::time::{SystemTime, UNIX_EPOCH}; +use crate::sync::{Arc, Mutex, MutexGuard, RwLock}; + + +use crate::lsps4::utils; + +/// The Intercepted HTLC store information will be persisted under this key. +pub(crate) const INTERCEPT_SCID_STORE_PERSISTENCE_PRIMARY_NAMESPACE: &str = "intercept_scids"; +pub(crate) const INTERCEPT_SCID_STORE_PERSISTENCE_SECONDARY_NAMESPACE: &str = ""; + + +/// Represents an intercepted HTLC that is stored in the data store +#[derive(Clone, Debug, PartialEq)] +pub struct ScidWithPeer { + scid: u64, + peer_id: PublicKey, +} + +impl ScidWithPeer { + pub fn new( + scid: u64, peer_id: PublicKey, + ) -> Self { + Self { + scid, + peer_id, + } + } + + pub fn store_key(&self) -> String { + utils::to_string(&self.scid.to_be_bytes()) + } + + pub fn scid(&self) -> u64 { + self.scid + } + + pub fn peer_id(&self) -> PublicKey { + self.peer_id + } +} + +impl_writeable_tlv_based!(ScidWithPeer, { + (0, scid, required), + (2, peer_id, required), +}); + +pub struct ScidStore +where L::Target: Logger, KV::Target: KVStoreSync { + peer_by_scid: RwLock>, + scid_by_peer: RwLock>, + kv_store: KV, + logger: L +} + +impl ScidStore +where L::Target: Logger, KV::Target: KVStoreSync { + pub(crate) fn new( + kv_store: KV, logger: L, + ) -> Result { + let mut scids = Vec::new(); + + for stored_key in kv_store.list( + INTERCEPT_SCID_STORE_PERSISTENCE_PRIMARY_NAMESPACE, + INTERCEPT_SCID_STORE_PERSISTENCE_SECONDARY_NAMESPACE, + )? { + let mut reader = Cursor::new(kv_store.read( + INTERCEPT_SCID_STORE_PERSISTENCE_PRIMARY_NAMESPACE, + INTERCEPT_SCID_STORE_PERSISTENCE_SECONDARY_NAMESPACE, + &stored_key, + )?); + let scid = ScidWithPeer::read(&mut reader).map_err(|e| { + log_error!(logger, "Failed to deserialize InterceptScid: {}", e); + io::Error::new( + io::ErrorKind::InvalidData, + "Failed to deserialize InterceptScid", + ) + })?; + scids.push(scid); + } + + let peer_by_scid = + RwLock::new(HashMap::from_iter(scids.iter().map(|obj| (obj.scid(), obj.peer_id())))); + + let scid_by_peer = + RwLock::new(HashMap::from_iter(scids.iter().map(|obj| (obj.peer_id(), obj.scid())))); + + Ok(Self { peer_by_scid, scid_by_peer, kv_store, logger }) + } + + pub(crate) fn insert(&self, scid: ScidWithPeer) -> Result { + let mut locked_peer_by_scid = self.peer_by_scid.write().unwrap(); + let mut locked_scid_by_peer = self.scid_by_peer.write().unwrap(); + + self.persist(&scid)?; + let updated = locked_peer_by_scid.insert(scid.scid(), scid.peer_id().clone()).is_some(); + locked_scid_by_peer.insert(scid.peer_id().clone(), scid.scid()); + Ok(updated) + } + + pub(crate) fn remove(&self, scid: u64) -> Result<(), io::Error> { + let mut locked_peer_by_scid = self.peer_by_scid.write().unwrap(); + let mut locked_scid_by_peer = self.scid_by_peer.write().unwrap(); + + let removed = locked_peer_by_scid.remove(&scid); + if let Some(peer_id) = removed { + locked_scid_by_peer.remove(&peer_id); + let store_key = utils::to_string(&scid.to_be_bytes()); + self.kv_store + .remove(INTERCEPT_SCID_STORE_PERSISTENCE_PRIMARY_NAMESPACE, INTERCEPT_SCID_STORE_PERSISTENCE_SECONDARY_NAMESPACE, &store_key, false) + .map_err(|e| { + log_error!( + self.logger, + "Removing htlc with intercept id {} failed due to: {}", + store_key, + e + ); + e + })?; + } + Ok(()) + } + + fn persist(&self, scid: &ScidWithPeer) -> Result<(), io::Error> { + let store_key = scid.store_key(); + let data = scid.encode(); + self.kv_store + .write(INTERCEPT_SCID_STORE_PERSISTENCE_PRIMARY_NAMESPACE, INTERCEPT_SCID_STORE_PERSISTENCE_SECONDARY_NAMESPACE, &store_key, data) + .map_err(|e| { + log_error!( + self.logger, + "Write for key {} failed due to: {}", + store_key, + e + ); + e + })?; + Ok(()) + } + + pub fn add_intercepted_scid( + &self, scid: u64, peer_id: PublicKey, + ) -> Result { + let scid = ScidWithPeer::new( + scid, + peer_id, + ); + self.insert(scid) + } + + pub fn get_peer(&self, scid: u64) -> Option { + self.peer_by_scid.read().unwrap().get(&scid).cloned() + } + + pub fn get_scid(&self, peer_id: &PublicKey) -> Option { + self.scid_by_peer.read().unwrap().get(peer_id).cloned() + } +} \ No newline at end of file diff --git a/lightning-liquidity/src/lsps4/service.rs b/lightning-liquidity/src/lsps4/service.rs new file mode 100644 index 00000000000..e1d051720ba --- /dev/null +++ b/lightning-liquidity/src/lsps4/service.rs @@ -0,0 +1,408 @@ +// 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 the main LSPS4 server-side object, [`LSPS4ServiceHandler`]. + +use crate::events::{LiquidityEvent, EventQueue}; +use crate::lsps0::ser::{ + LSPSMessage, LSPSProtocolMessageHandler, LSPSRequestId, LSPSResponseError, + JSONRPC_INTERNAL_ERROR_ERROR_CODE, JSONRPC_INTERNAL_ERROR_ERROR_MESSAGE, + LSPS0_CLIENT_REJECTED_ERROR_CODE, +}; +use crate::lsps4::event::LSPS4ServiceEvent; +use crate::lsps4::htlc_store::{HTLCStore, InterceptedHtlc}; +use crate::lsps4::scid_store::ScidStore; +use crate::lsps4::utils::compute_forward_fee; +use crate::message_queue::MessageQueue; +use crate::prelude::hash_map::Entry; +use crate::prelude::{new_hash_map, HashMap}; +use crate::sync::{Arc, Mutex, MutexGuard, RwLock}; + +use lightning::ln::channelmanager::{AChannelManager, InterceptId}; +use lightning::ln::msgs::{ErrorAction, LightningError}; +use lightning::ln::types::ChannelId; +use lightning::{log_debug, log_error, log_info}; +use lightning::util::errors::APIError; +use lightning::util::logger::{Level, Logger}; + +use lightning::util::persist::KVStoreSync; +use lightning_types::payment::PaymentHash; + +use bitcoin::secp256k1::PublicKey; + +use core::ops::Deref; +use core::sync::atomic::{AtomicUsize, Ordering}; +use std::collections::HashSet; + +use crate::lsps4::msgs::{ + RegisterNodeRequest, RegisterNodeResponse, LSPS4Message, LSPS4Request, + LSPS4Response, +}; + +use std::string::String; +use std::vec::Vec; + +const HTLC_EXPIRY_THRESHOLD_SECS: u64 = 10; + +/// Action to forward a specific HTLC through a channel +#[derive(Debug)] +pub(crate) struct HtlcForwardAction { + pub htlc: InterceptedHtlc, + pub channel_id: ChannelId, +} + +/// Actions to take for processing HTLCs for a peer +#[derive(Debug)] +pub(crate) struct HtlcProcessingActions { + pub forwards: Vec, + pub new_channel_needed_msat: Option, +} + +impl HtlcProcessingActions { + pub fn is_empty(&self) -> bool { + self.forwards.is_empty() && self.new_channel_needed_msat.is_none() + } + + pub fn total_forward_amount(&self) -> u64 { + self.forwards.iter().map(|f| f.htlc.expected_outbound_amount_msat()).sum() + } +} + +/// Server-side configuration options for JIT channels. +#[derive(Clone, Debug)] +pub struct LSPS4ServiceConfig { + /// The CLTV expiry delta to use for the JIT channels. + pub cltv_expiry_delta: u32, +} + +/// The main object allowing to send and receive LSPS4 messages. +pub struct LSPS4ServiceHandler +where + CM::Target: AChannelManager, + KS::Target: lightning::util::persist::KVStoreSync, + L::Target: Logger, +{ + channel_manager: CM, + kv_store_sync: KS, + logger: L, + pending_messages: Arc, + pending_events: Arc>>, + scid_store: ScidStore, + htlc_store: HTLCStore, + connected_peers: RwLock>, + config: LSPS4ServiceConfig, +} + +impl LSPS4ServiceHandler +where + CM::Target: AChannelManager, + KS::Target: lightning::util::persist::KVStoreSync, + L::Target: Logger, +{ + /// Constructs a `LSPS4ServiceHandler`. + pub(crate) fn new( + pending_messages: Arc, + pending_events: Arc>>, + channel_manager: CM, + config: LSPS4ServiceConfig, + kv_store_sync: KS, + logger: L, + ) -> Result { + Ok(Self { + pending_messages, + pending_events, + scid_store: ScidStore::new(kv_store_sync.clone(), logger.clone())?, + htlc_store: HTLCStore::new(kv_store_sync.clone(), logger.clone())?, + channel_manager, + kv_store_sync, + config, + logger, + connected_peers: RwLock::new(HashSet::new()), + }) + } + + /// Forward [`Event::HTLCIntercepted`] event parameters into this function. + /// + /// Will generate a [`LSPS4ServiceEvent::OpenChannel`] event if the intercept scid + /// matches and the user needs more liquidity. + /// + /// Will do nothing if the intercept scid does not match any of the ones we gave out. + /// + /// [`Event::HTLCIntercepted`]: lightning::events::Event::HTLCIntercepted + /// [`LSPS4ServiceEvent::OpenChannel`]: crate::lsps4::event::LSPS4ServiceEvent::OpenChannel + pub fn htlc_intercepted( + &self, intercept_scid: u64, intercept_id: InterceptId, expected_outbound_amount_msat: u64, + payment_hash: PaymentHash, + ) -> Result<(), APIError> { + if let Some(counterparty_node_id) = self.scid_store.get_peer(intercept_scid) { + let htlc = InterceptedHtlc::new( + intercept_id, + intercept_scid, + expected_outbound_amount_msat, + payment_hash, + counterparty_node_id.clone(), + ); + + if !self.is_peer_connected(&counterparty_node_id) { + self.htlc_store.insert(htlc).unwrap(); // TODO: handle persistence failures + let mut event_queue_notifier = self.pending_events.notifier(); + event_queue_notifier.enqueue(crate::events::LiquidityEvent::LSPS4Service(LSPS4ServiceEvent::SendWebhook { + counterparty_node_id: counterparty_node_id.clone(), + })); + } else { + let actions = self.calculate_htlc_actions_for_peer(counterparty_node_id,vec![htlc.clone()]); + + if actions.new_channel_needed_msat.is_some() { + self.htlc_store.insert(htlc).unwrap(); // TODO: handle persistence failures + } + + self.execute_htlc_actions(actions, counterparty_node_id.clone()); + } + } + + Ok(()) + } + + /// Forward [`Event::ChannelReady`] event parameters into this function. + /// + /// Will attempt to forward pending htlcs for this counterparty if there are any. + /// + /// [`Event::ChannelReady`]: lightning::events::Event::ChannelReady + pub fn channel_ready( + &self, counterparty_node_id: &PublicKey, + ) -> Result<(), APIError> { + + if self.is_peer_connected(counterparty_node_id) { + let htlcs = self.htlc_store.get_htlcs_by_node_id(counterparty_node_id); + self.process_htlcs_for_peer(counterparty_node_id.clone(), htlcs); + } + + Ok(()) + } + + /// Will attempt to forward any pending intercepted htlcs to this counterparty. + pub fn peer_connected(&self, counterparty_node_id: PublicKey) { + log_info!(self.logger, "Peer connected: {} inserting into connected peers map", counterparty_node_id); + + self.connected_peers.write().unwrap().insert(counterparty_node_id); + + let htlcs = self.htlc_store.get_htlcs_by_node_id(&counterparty_node_id); + + log_info!(self.logger, "{} has {} htlcs waiting to be forwarded", counterparty_node_id, htlcs.len()); + + self.process_htlcs_for_peer(counterparty_node_id.clone(), htlcs); + } + + /// Handle expired HTLCs. + /// + /// Will fail the HTLCs and remove them from the store. + /// Needs to be called regularly to cleanup old htlcs. + pub fn handle_expired_htlcs(&self, now: u64) { + for htlc in self.htlc_store.get_expired_htlcs(now, HTLC_EXPIRY_THRESHOLD_SECS) { + if let Err(e) = self.channel_manager.get_cm().fail_intercepted_htlc(htlc.id()) { + log_error!( + self.logger, + "HTLC was already failed when we tried to fail it: {:?}", + e + ); + } + + if let Err(e) = self.htlc_store.remove(&htlc.id()) { + log_error!( + self.logger, + "Failed to remove expired intercepted HTLC from store: {}", + e + ); + } + } + } + + fn is_peer_connected(&self, counterparty_node_id: &PublicKey) -> bool { + self.connected_peers.read().unwrap().contains(counterparty_node_id) + } + + /// Will update the set of connected peers + pub fn peer_disconnected(&self, counterparty_node_id: &PublicKey) { + self.connected_peers.write().unwrap().remove(counterparty_node_id); + } + + fn handle_register_node_request( + &self, request_id: LSPSRequestId, counterparty_node_id: &PublicKey, _params: RegisterNodeRequest, + ) -> Result<(), LightningError> { + let intercept_scid = match self.scid_store.get_scid(counterparty_node_id) { + Some(intercept_scid) => intercept_scid, + None => { + let intercept_scid = self.channel_manager.get_cm().get_intercept_scid(); + self.scid_store.add_intercepted_scid(intercept_scid, counterparty_node_id.clone()).unwrap(); + intercept_scid + } + }; + let mut message_queue_notifier = self.pending_messages.notifier(); + message_queue_notifier.enqueue(counterparty_node_id, LSPS4Message::Response(request_id, LSPS4Response::RegisterNode(RegisterNodeResponse { + jit_channel_scid: intercept_scid.into(), + lsp_cltv_expiry_delta: self.config.cltv_expiry_delta, + })).into()); + Ok(()) + } + + /// Calculate what actions to take for a list of HTLCs for a specific peer + /// This is a pure function that doesn't perform any side effects + pub(crate) fn calculate_htlc_actions_for_peer( + &self, their_node_id: PublicKey, mut htlcs: Vec, + ) -> HtlcProcessingActions { + let channels = self.channel_manager.get_cm().list_channels_with_counterparty(&their_node_id); + + let mut channel_capacity_map: HashMap = new_hash_map(); + for channel in &channels { + channel_capacity_map.insert(channel.channel_id, channel.outbound_capacity_msat); + } + + log_info!(self.logger, "{} has {} channels with these outbound capacities: {:?}", their_node_id, channels.len(), channel_capacity_map); + + let mut forwards = Vec::new(); + + while let Some(htlc) = htlcs.pop() { + let required_amount = htlc.expected_outbound_amount_msat(); + let mut can_forward = false; + + // Check if any existing channel has sufficient remaining capacity + for (channel_id, remaining_capacity) in channel_capacity_map.iter_mut() { + if *remaining_capacity >= required_amount { + // Plan to forward this HTLC through this channel + forwards.push(HtlcForwardAction { htlc, channel_id: *channel_id }); + + // Update remaining capacity after planning the forward + *remaining_capacity -= required_amount; + can_forward = true; + break; + } + } + + if !can_forward { + // No existing channel has sufficient capacity, need to open a new channel + // Calculate total amount needed for remaining HTLCs (including current one) + let mut total_remaining_amount = required_amount; + for remaining_htlc in &htlcs { + total_remaining_amount += remaining_htlc.expected_outbound_amount_msat(); + } + + return HtlcProcessingActions { + forwards, + new_channel_needed_msat: Some(total_remaining_amount), + }; + } + } + + HtlcProcessingActions { forwards, new_channel_needed_msat: None } + } + + /// Execute the actions calculated by calculate_htlc_actions_for_peer + pub(crate) fn execute_htlc_actions( + &self, actions: HtlcProcessingActions, their_node_id: PublicKey, + ) { + // Execute forwards + for forward_action in actions.forwards { + log_debug!( + self.logger, + "Executing forward for HTLC {:?} through channel {} with amount {}msat", + forward_action.htlc.id(), + forward_action.channel_id, + forward_action.htlc.expected_outbound_amount_msat() + ); + + if let Err(e) = self.channel_manager.get_cm().forward_intercepted_htlc( + forward_action.htlc.id(), + &forward_action.channel_id, + forward_action.htlc.next_node_id(), + forward_action.htlc.expected_outbound_amount_msat(), + ) { + log_error!(self.logger, "Failed to forward intercepted HTLC: {:?}", e); + } + + // Remove the HTLC from store after forwarding + if let Err(e) = self.htlc_store.remove(&forward_action.htlc.id()) { + log_error!(self.logger, "Failed to remove intercepted HTLC from store: {}", e); + } + } + + // Handle new channel opening + if let Some(channel_size_msat) = actions.new_channel_needed_msat { + log_info!( + self.logger, + "Need a new channel with peer {} for {}msat to forward HTLCs", + their_node_id, + channel_size_msat + ); + + let mut event_queue_notifier = self.pending_events.notifier(); + event_queue_notifier.enqueue(crate::events::LiquidityEvent::LSPS4Service(LSPS4ServiceEvent::OpenChannel { + their_network_key: their_node_id, + amt_to_forward_msat: channel_size_msat, + })); + } + } + + /// Convenience function that calculates and executes HTLC actions in one call + pub(crate) fn process_htlcs_for_peer( + &self, their_node_id: PublicKey, htlcs: Vec, + ) { + let actions = self.calculate_htlc_actions_for_peer(their_node_id, htlcs); + + log_info!(self.logger, "Calculated actions for peer {}: {:?}", their_node_id, actions); + + if actions.forwards.is_empty() && actions.new_channel_needed_msat.is_none() { + log_debug!(self.logger, "No HTLCs to process for peer {}", their_node_id); + return; + } + + if !actions.forwards.is_empty() { + log_debug!( + self.logger, + "Processing {} HTLCs for peer {}", + actions.forwards.len(), + their_node_id + ); + } + + self.execute_htlc_actions(actions, their_node_id); + } + +} + +impl LSPSProtocolMessageHandler for LSPS4ServiceHandler +where + CM::Target: AChannelManager, + KV::Target: KVStoreSync, + L::Target: Logger, +{ + type ProtocolMessage = LSPS4Message; + const PROTOCOL_NUMBER: Option = Some(4); + + fn handle_message( + &self, message: Self::ProtocolMessage, counterparty_node_id: &PublicKey, + ) -> Result<(), LightningError> { + match message { + LSPS4Message::Request(request_id, request) => { + let res = match request { + LSPS4Request::RegisterNode(params) => { + self.handle_register_node_request(request_id, counterparty_node_id, params) + }, + }; + res + }, + _ => { + debug_assert!( + false, + "Service handler received LSPS4 response message. This should never happen." + ); + Err(LightningError { err: format!("Service handler received LSPS4 response message from node {:?}. This should never happen.", counterparty_node_id), action: ErrorAction::IgnoreAndLog(Level::Info)}) + }, + } + } +} \ No newline at end of file diff --git a/lightning-liquidity/src/lsps4/utils.rs b/lightning-liquidity/src/lsps4/utils.rs new file mode 100644 index 00000000000..8c0c29b0522 --- /dev/null +++ b/lightning-liquidity/src/lsps4/utils.rs @@ -0,0 +1,46 @@ +//! Utilities for implementing the LSPS4 standard. +use std::fmt::Write; + +/// Computes the forward fee given a payment size and the fee parameters. +/// +/// Returns [`Option::None`] when the computation overflows. +pub fn compute_forward_fee( + payment_size_msat: u64, fee_proportional: u64, +) -> Option { + payment_size_msat.checked_mul(fee_proportional).and_then(|f| f.checked_add(999999)).and_then(|f| f.checked_div(1000000)) +} + +/// Convert a byte slice to a hex string. +#[inline] +pub fn to_string(value: &[u8]) -> String { + let mut res = String::with_capacity(2 * value.len()); + for v in value { + write!(&mut res, "{:02x}", v).expect("Unable to write"); + } + res +} + +#[cfg(test)] +mod tests { + use super::*; + use proptest::prelude::*; + + const MAX_VALUE_MSAT: u64 = 21_000_000_0000_0000_000; + + fn arb_forward_fee_params() -> impl Strategy { + (0u64..MAX_VALUE_MSAT, 0u64..MAX_VALUE_MSAT) + } + + proptest! { + #[test] + fn test_compute_forward_fee((payment_size_msat, fee_proportional) in arb_forward_fee_params()) { + if let Some(res) = compute_forward_fee(payment_size_msat, fee_proportional) { + assert_eq!(res as f32, (payment_size_msat as f32 * fee_proportional as f32)); + } else { + // Check we actually overflowed. + let max_value = u64::MAX as u128; + assert!((payment_size_msat as u128 * fee_proportional as u128) > max_value); + } + } + } +} diff --git a/lightning-liquidity/src/manager.rs b/lightning-liquidity/src/manager.rs index 22cf8cc1d9c..c4ab6c66788 100644 --- a/lightning-liquidity/src/manager.rs +++ b/lightning-liquidity/src/manager.rs @@ -36,6 +36,9 @@ use crate::lsps1::service::{LSPS1ServiceConfig, LSPS1ServiceHandler}; use crate::lsps2::client::{LSPS2ClientConfig, LSPS2ClientHandler}; use crate::lsps2::msgs::LSPS2Message; use crate::lsps2::service::{LSPS2ServiceConfig, LSPS2ServiceHandler, LSPS2ServiceHandlerSync}; +use crate::lsps4::client::{LSPS4ClientConfig, LSPS4ClientHandler}; +use crate::lsps4::msgs::LSPS4Message; +use crate::lsps4::service::{LSPS4ServiceConfig, LSPS4ServiceHandler}; use crate::prelude::{new_hash_map, new_hash_set, HashMap, HashSet}; use crate::sync::{Arc, Mutex, RwLock}; use crate::utils::async_poll::dummy_waker; @@ -50,7 +53,7 @@ use lightning::ln::msgs::{ErrorAction, LightningError}; use lightning::ln::peer_handler::CustomMessageHandler; use lightning::ln::wire::CustomMessageReader; use lightning::sign::{EntropySource, NodeSigner}; -use lightning::util::logger::Level; +use lightning::util::logger::{Level, Logger}; use lightning::util::persist::{KVStore, KVStoreSync, KVStoreSyncWrapper}; use lightning::util::ser::{LengthLimitedRead, LengthReadable}; use lightning::util::wakers::{Future, Notifier}; @@ -77,6 +80,8 @@ pub struct LiquidityServiceConfig { /// Optional server-side configuration for JIT channels /// should you want to support them. pub lsps2_service_config: Option, + /// Optional server-side configuration for LSPS4 channels. + pub lsps4_service_config: Option, /// Optional server-side configuration for LSPS5 webhook service. pub lsps5_service_config: Option, /// Controls whether the liquidity service should be advertised via setting the feature bit in @@ -94,6 +99,8 @@ pub struct LiquidityClientConfig { pub lsps1_client_config: Option, /// Optional client-side configuration for JIT channels. pub lsps2_client_config: Option, + /// Optional client-side configuration for LSPS4 channels. + pub lsps4_client_config: Option, /// Optional client-side configuration for LSPS5 webhook service. pub lsps5_client_config: Option, } @@ -207,6 +214,10 @@ pub trait ALiquidityManagerSync { type BroadcasterInterface: BroadcasterInterface + ?Sized; /// A type that may be dereferenced to [`Self::BroadcasterInterface`]. type T: Deref + Clone; + /// A type implementing [`Logger`]. + type Logger: Logger + ?Sized; + /// A type that may be dereferenced to [`Self::Logger`]. + type L: Deref + Clone; /// Returns the inner async [`LiquidityManager`] for testing purposes. #[cfg(any(test, feature = "_test_utils"))] fn get_lm_async( @@ -223,7 +234,7 @@ pub trait ALiquidityManagerSync { /// Returns a reference to the actual [`LiquidityManager`] object. fn get_lm( &self, - ) -> &LiquidityManagerSync; + ) -> &LiquidityManagerSync; } impl< @@ -234,7 +245,8 @@ impl< KS: Deref + Clone, TP: Deref + Clone, T: Deref + Clone, - > ALiquidityManagerSync for LiquidityManagerSync + L: Deref + Clone, + > ALiquidityManagerSync for LiquidityManagerSync where ES::Target: EntropySource, NS::Target: NodeSigner, @@ -243,6 +255,7 @@ where KS::Target: KVStoreSync, TP::Target: TimeProvider, T::Target: BroadcasterInterface, + L::Target: Logger, { type EntropySource = ES::Target; type ES = ES; @@ -258,6 +271,8 @@ where type TP = TP; type BroadcasterInterface = T::Target; type T = T; + type Logger = L::Target; + type L = L; /// Returns the inner async [`LiquidityManager`] for testing purposes. #[cfg(any(test, feature = "_test_utils"))] fn get_lm_async( @@ -273,7 +288,7 @@ where > { &self.inner } - fn get_lm(&self) -> &LiquidityManagerSync { + fn get_lm(&self) -> &LiquidityManagerSync { self } } @@ -326,6 +341,7 @@ pub struct LiquidityManager< lsps1_client_handler: Option>, lsps2_service_handler: Option>, lsps2_client_handler: Option>, + lsps4_client_handler: Option>, lsps5_service_handler: Option>, lsps5_client_handler: Option>, service_config: Option, @@ -458,6 +474,17 @@ where None }; + let lsps4_client_handler = client_config.as_ref().and_then(|config| { + config.lsps4_client_config.map(|config| { + LSPS4ClientHandler::new( + entropy_source.clone(), + Arc::clone(&pending_messages), + Arc::clone(&pending_events), + config.clone(), + ) + }) + }); + let lsps5_client_handler = client_config.as_ref().and_then(|config| { config.lsps5_client_config.as_ref().map(|config| { LSPS5ClientHandler::new( @@ -549,6 +576,7 @@ where lsps1_service_handler, lsps2_client_handler, lsps2_service_handler, + lsps4_client_handler, lsps5_client_handler, lsps5_service_handler, service_config, @@ -599,6 +627,16 @@ where self.lsps2_service_handler.as_ref() } + /// Returns a reference to the LSPS4 client-side handler. + /// + /// The returned hendler allows to initiate the LSPS4 client-side flow. That is, it allows to + /// retrieve all necessary data to create 'just-in-time' invoices that, when paid, will have + /// the configured LSP open a 'just-in-time' channel. + pub fn lsps4_client_handler(&self) -> Option<&LSPS4ClientHandler> { + self.lsps4_client_handler.as_ref() + } + + /// Returns a reference to the LSPS5 client-side handler. /// /// The returned handler allows to initiate the LSPS5 client-side flow. That is, it allows to @@ -756,6 +794,20 @@ where }, } }, + LSPSMessage::LSPS4(msg @ LSPS4Message::Response(..)) => { + match &self.lsps4_client_handler { + Some(lsps4_client_handler) => { + lsps4_client_handler.handle_message(msg, sender_node_id)?; + }, + None => { + return Err(LightningError { err: format!("Received LSPS4 response message without LSPS4 client handler configured. From node = {:?}", sender_node_id), action: ErrorAction::IgnoreAndLog(Level::Info)}); + }, + } + }, + LSPSMessage::LSPS4(LSPS4Message::Request(..)) => { + // LSPS4 service handler is only available in LiquidityManagerSync + return Err(LightningError { err: format!("Received LSPS4 request message without LSPS4 service handler configured. From node = {:?}", sender_node_id), action: ErrorAction::IgnoreAndLog(Level::Info)}); + }, LSPSMessage::LSPS5(msg @ LSPS5Message::Response(..)) => { match &self.lsps5_client_handler { Some(lsps5_client_handler) => { @@ -954,6 +1006,8 @@ where lsps2_service_handler.peer_disconnected(counterparty_node_id); } + // LSPS4 service handler is only available in LiquidityManagerSync + if let Some(lsps5_service_handler) = self.lsps5_service_handler.as_ref() { lsps5_service_handler.peer_disconnected(&counterparty_node_id); } @@ -962,6 +1016,9 @@ where &self, counterparty_node_id: bitcoin::secp256k1::PublicKey, _: &lightning::ln::msgs::Init, _: bool, ) -> Result<(), ()> { + + // LSPS4 service handler is only available in LiquidityManagerSync + if let Some(lsps5_service_handler) = self.lsps5_service_handler.as_ref() { lsps5_service_handler.peer_connected(&counterparty_node_id); } @@ -1070,6 +1127,7 @@ pub struct LiquidityManagerSync< KS: Deref + Clone, TP: Deref + Clone, T: Deref + Clone, + L: Deref + Clone, > where ES::Target: EntropySource, NS::Target: NodeSigner, @@ -1078,8 +1136,10 @@ pub struct LiquidityManagerSync< KS::Target: KVStoreSync, TP::Target: TimeProvider, T::Target: BroadcasterInterface, + L::Target: Logger, { inner: LiquidityManager, TP, T>, + lsps4_service_handler: Option>, } #[cfg(feature = "time")] @@ -1090,7 +1150,8 @@ impl< C: Deref + Clone, KS: Deref + Clone, T: Deref + Clone, - > LiquidityManagerSync + L: Deref + Clone + > LiquidityManagerSync where ES::Target: EntropySource, NS::Target: NodeSigner, @@ -1098,6 +1159,7 @@ where KS::Target: KVStoreSync, C::Target: Filter, T::Target: BroadcasterInterface, + L::Target: Logger, { /// Constructor for the [`LiquidityManagerSync`] using the default system clock /// @@ -1107,18 +1169,19 @@ where chain_params: Option, kv_store_sync: KS, transaction_broadcaster: T, service_config: Option, client_config: Option, + logger: L, ) -> Result { - let kv_store = KVStoreSyncWrapper(kv_store_sync); + let kv_store = KVStoreSyncWrapper(kv_store_sync.clone()); let mut fut = Box::pin(LiquidityManager::new( - entropy_source, - node_signer, - channel_manager, + entropy_source.clone(), + node_signer.clone(), + channel_manager.clone(), chain_source, chain_params, kv_store, transaction_broadcaster, - service_config, + service_config.clone(), client_config, )); @@ -1131,7 +1194,26 @@ where unreachable!("LiquidityManager::new should not be pending in a sync context"); }, }?; - Ok(Self { inner }) + + // Create LSPS4 service handler with sync store if service is configured + let lsps4_service_handler = if let Some(ref config) = service_config { + if config.lsps4_service_config.is_some() { + Some(LSPS4ServiceHandler::new( + inner.pending_messages.clone(), + inner.pending_events.clone(), + channel_manager, + config.lsps4_service_config.clone().unwrap(), + kv_store_sync, + logger, + )?) + } else { + None + } + } else { + None + }; + + Ok(Self { inner, lsps4_service_handler }) } } @@ -1143,7 +1225,8 @@ impl< KS: Deref + Clone, TP: Deref + Clone, T: Deref + Clone, - > LiquidityManagerSync + L: Deref + Clone + > LiquidityManagerSync where ES::Target: EntropySource, NS::Target: NodeSigner, @@ -1152,6 +1235,7 @@ where KS::Target: KVStoreSync, TP::Target: TimeProvider, T::Target: BroadcasterInterface, + L::Target: Logger, { /// Constructor for the [`LiquidityManagerSync`] with a custom time provider. /// @@ -1161,17 +1245,18 @@ where chain_params: Option, kv_store_sync: KS, transaction_broadcaster: T, service_config: Option, client_config: Option, time_provider: TP, + logger: L, ) -> Result { - let kv_store = KVStoreSyncWrapper(kv_store_sync); + let kv_store = KVStoreSyncWrapper(kv_store_sync.clone()); let mut fut = Box::pin(LiquidityManager::new_with_custom_time_provider( - entropy_source, - node_signer, - channel_manager, + entropy_source.clone(), + node_signer.clone(), + channel_manager.clone(), transaction_broadcaster, chain_source, chain_params, kv_store, - service_config, + service_config.clone(), client_config, time_provider, )); @@ -1185,7 +1270,26 @@ where unreachable!("LiquidityManager::new should not be pending in a sync context"); }, }?; - Ok(Self { inner }) + + // Create LSPS4 service handler with sync store if service is configured + let lsps4_service_handler = if let Some(ref config) = service_config { + if config.lsps4_service_config.is_some() { + Some(LSPS4ServiceHandler::new( + inner.pending_messages.clone(), + inner.pending_events.clone(), + channel_manager, + config.lsps4_service_config.clone().unwrap(), + kv_store_sync, + logger, + )?) + } else { + None + } + } else { + None + }; + + Ok(Self { inner, lsps4_service_handler }) } /// Returns a reference to the LSPS0 client-side handler. @@ -1235,6 +1339,22 @@ where self.inner.lsps2_service_handler.as_ref().map(|r| LSPS2ServiceHandlerSync::from_inner(r)) } + /// Returns a reference to the LSPS4 client-side handler. + /// + /// The returned hendler allows to initiate the LSPS4 client-side flow. That is, it allows to + /// retrieve all necessary data to create 'just-in-time' invoices that, when paid, will have + /// the configured LSP open a 'just-in-time' channel. + pub fn lsps4_client_handler(&self) -> Option<&LSPS4ClientHandler>> { + self.inner.lsps4_client_handler() + } + + /// Returns a reference to the LSPS4 server-side handler. + /// + /// The returned hendler allows to initiate the LSPS4 service-side flow. + pub fn lsps4_service_handler(&self) -> Option<&LSPS4ServiceHandler> { + self.lsps4_service_handler.as_ref() + } + /// Returns a reference to the LSPS5 client-side handler. /// /// Wraps [`LiquidityManager::lsps5_client_handler`]. @@ -1313,7 +1433,8 @@ impl< KS: Deref + Clone, TP: Deref + Clone, T: Deref + Clone, - > CustomMessageReader for LiquidityManagerSync + L: Deref + Clone, + > CustomMessageReader for LiquidityManagerSync where ES::Target: EntropySource, NS::Target: NodeSigner, @@ -1322,6 +1443,7 @@ where KS::Target: KVStoreSync, TP::Target: TimeProvider, T::Target: BroadcasterInterface, + L::Target: Logger, { type CustomMessage = RawLSPSMessage; @@ -1340,7 +1462,8 @@ impl< KS: Deref + Clone, TP: Deref + Clone, T: Deref + Clone, - > CustomMessageHandler for LiquidityManagerSync + L: Deref + Clone, + > CustomMessageHandler for LiquidityManagerSync where ES::Target: EntropySource, NS::Target: NodeSigner, @@ -1349,10 +1472,25 @@ where KS::Target: KVStoreSync, TP::Target: TimeProvider, T::Target: BroadcasterInterface, + L::Target: Logger, { fn handle_custom_message( &self, msg: Self::CustomMessage, sender_node_id: PublicKey, ) -> Result<(), lightning::ln::msgs::LightningError> { + // Parse the message first to see if it's an LSPS4 service request + if let Some(ref handler) = self.lsps4_service_handler { + let parsed_msg = { + let mut request_id_to_method_map = self.inner.request_id_to_method_map.lock().unwrap(); + LSPSMessage::from_str_with_id_map(&msg.payload, &mut request_id_to_method_map) + }; + + if let Ok(LSPSMessage::LSPS4(lsps4_msg @ LSPS4Message::Request(..))) = parsed_msg { + // Handle LSPS4 service requests with our sync handler + return handler.handle_message(lsps4_msg, &sender_node_id); + } + } + + // Delegate everything else to the inner manager self.inner.handle_custom_message(msg, sender_node_id) } @@ -1369,12 +1507,22 @@ where } fn peer_disconnected(&self, counterparty_node_id: bitcoin::secp256k1::PublicKey) { + // Notify LSPS4 service handler if present + if let Some(ref handler) = self.lsps4_service_handler { + handler.peer_disconnected(&counterparty_node_id); + } + self.inner.peer_disconnected(counterparty_node_id) } fn peer_connected( &self, counterparty_node_id: bitcoin::secp256k1::PublicKey, init_msg: &lightning::ln::msgs::Init, inbound: bool, ) -> Result<(), ()> { + // Notify LSPS4 service handler if present + if let Some(ref handler) = self.lsps4_service_handler { + handler.peer_connected(counterparty_node_id); + } + self.inner.peer_connected(counterparty_node_id, init_msg, inbound) } } @@ -1387,7 +1535,8 @@ impl< KS: Deref + Clone, TP: Deref + Clone, T: Deref + Clone, - > Listen for LiquidityManagerSync + L: Deref + Clone, + > Listen for LiquidityManagerSync where ES::Target: EntropySource, NS::Target: NodeSigner, @@ -1396,6 +1545,7 @@ where KS::Target: KVStoreSync, TP::Target: TimeProvider, T::Target: BroadcasterInterface, + L::Target: Logger, { fn filtered_block_connected( &self, header: &bitcoin::block::Header, txdata: &chain::transaction::TransactionData, @@ -1417,7 +1567,8 @@ impl< KS: Deref + Clone, TP: Deref + Clone, T: Deref + Clone, - > Confirm for LiquidityManagerSync + L: Deref + Clone, + > Confirm for LiquidityManagerSync where ES::Target: EntropySource, NS::Target: NodeSigner, @@ -1426,6 +1577,7 @@ where KS::Target: KVStoreSync, TP::Target: TimeProvider, T::Target: BroadcasterInterface, + L::Target: Logger, { fn transactions_confirmed( &self, header: &bitcoin::block::Header, txdata: &chain::transaction::TransactionData, From 3bd7056d5db5426c237f75bcdb9b7e12f34e774b Mon Sep 17 00:00:00 2001 From: Martin Saposnic Date: Wed, 5 Nov 2025 09:46:03 -0300 Subject: [PATCH 100/135] formatting --- lightning-liquidity/src/lsps4/mod.rs | 2 +- lightning-liquidity/src/lsps4/utils.rs | 9 +++++---- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/lightning-liquidity/src/lsps4/mod.rs b/lightning-liquidity/src/lsps4/mod.rs index d5c4987d7f6..c881c24affa 100644 --- a/lightning-liquidity/src/lsps4/mod.rs +++ b/lightning-liquidity/src/lsps4/mod.rs @@ -11,8 +11,8 @@ pub mod client; pub mod event; -pub mod msgs; pub(crate) mod htlc_store; +pub mod msgs; pub(crate) mod scid_store; pub mod service; pub mod utils; diff --git a/lightning-liquidity/src/lsps4/utils.rs b/lightning-liquidity/src/lsps4/utils.rs index 8c0c29b0522..25b3cf24508 100644 --- a/lightning-liquidity/src/lsps4/utils.rs +++ b/lightning-liquidity/src/lsps4/utils.rs @@ -4,10 +4,11 @@ use std::fmt::Write; /// Computes the forward fee given a payment size and the fee parameters. /// /// Returns [`Option::None`] when the computation overflows. -pub fn compute_forward_fee( - payment_size_msat: u64, fee_proportional: u64, -) -> Option { - payment_size_msat.checked_mul(fee_proportional).and_then(|f| f.checked_add(999999)).and_then(|f| f.checked_div(1000000)) +pub fn compute_forward_fee(payment_size_msat: u64, fee_proportional: u64) -> Option { + payment_size_msat + .checked_mul(fee_proportional) + .and_then(|f| f.checked_add(999999)) + .and_then(|f| f.checked_div(1000000)) } /// Convert a byte slice to a hex string. From 4e27fadf05fb0f06100d8ed55ea4757075cd2bf6 Mon Sep 17 00:00:00 2001 From: Martin Saposnic Date: Wed, 5 Nov 2025 11:25:30 -0300 Subject: [PATCH 101/135] add logic to skim fees --- lightning-liquidity/src/lsps4/service.rs | 82 +++++++++++++++++++----- 1 file changed, 67 insertions(+), 15 deletions(-) diff --git a/lightning-liquidity/src/lsps4/service.rs b/lightning-liquidity/src/lsps4/service.rs index e1d051720ba..fbf8a8e5322 100644 --- a/lightning-liquidity/src/lsps4/service.rs +++ b/lightning-liquidity/src/lsps4/service.rs @@ -55,6 +55,8 @@ const HTLC_EXPIRY_THRESHOLD_SECS: u64 = 10; pub(crate) struct HtlcForwardAction { pub htlc: InterceptedHtlc, pub channel_id: ChannelId, + pub amount_to_forward_msat: u64, + pub skimmed_fee_msat: u64, } /// Actions to take for processing HTLCs for a peer @@ -70,7 +72,7 @@ impl HtlcProcessingActions { } pub fn total_forward_amount(&self) -> u64 { - self.forwards.iter().map(|f| f.htlc.expected_outbound_amount_msat()).sum() + self.forwards.iter().map(|f| f.amount_to_forward_msat).sum() } } @@ -79,6 +81,8 @@ impl HtlcProcessingActions { pub struct LSPS4ServiceConfig { /// The CLTV expiry delta to use for the JIT channels. pub cltv_expiry_delta: u32, + /// The proportional fee, in millionths, to skim from forwarded payments. + pub forwarding_fee_proportional_millionths: u64, } /// The main object allowing to send and receive LSPS4 messages. @@ -265,17 +269,64 @@ where log_info!(self.logger, "{} has {} channels with these outbound capacities: {:?}", their_node_id, channels.len(), channel_capacity_map); + struct ComputedHtlc { + htlc: InterceptedHtlc, + amount_to_forward_msat: u64, + skimmed_fee_msat: u64, + } + + let mut computed_htlcs: Vec = htlcs.drain(..).map(|htlc| { + let expected_outbound_msat = htlc.expected_outbound_amount_msat(); + if expected_outbound_msat == 0 { + return ComputedHtlc { htlc, amount_to_forward_msat: 0, skimmed_fee_msat: 0 }; + } + + let htlc_id = htlc.id(); + let mut fee_msat = match crate::lsps4::utils::compute_forward_fee( + expected_outbound_msat, + self.config.forwarding_fee_proportional_millionths, + ) { + Some(fee) => core::cmp::min(fee, expected_outbound_msat), + None => { + log_error!( + self.logger, + "Overflow while computing skimmed fee for intercepted HTLC {:?}. Skipping skim.", + htlc_id + ); + 0 + }, + }; + + let mut amount_to_forward_msat = expected_outbound_msat.saturating_sub(fee_msat); + if amount_to_forward_msat == 0 && fee_msat > 0 { + log_error!( + self.logger, + "Skimmed fee equaled the entire HTLC amount for {:?}. Skipping skim.", + htlc_id + ); + fee_msat = 0; + amount_to_forward_msat = expected_outbound_msat; + } + + ComputedHtlc { htlc, amount_to_forward_msat, skimmed_fee_msat: fee_msat } + }).collect(); + let mut forwards = Vec::new(); - while let Some(htlc) = htlcs.pop() { - let required_amount = htlc.expected_outbound_amount_msat(); + while let Some(computed) = computed_htlcs.pop() { + let required_amount = computed.amount_to_forward_msat; let mut can_forward = false; // Check if any existing channel has sufficient remaining capacity for (channel_id, remaining_capacity) in channel_capacity_map.iter_mut() { if *remaining_capacity >= required_amount { // Plan to forward this HTLC through this channel - forwards.push(HtlcForwardAction { htlc, channel_id: *channel_id }); + forwards.push(HtlcForwardAction { + htlc: computed.htlc, + channel_id: *channel_id, + amount_to_forward_msat: computed.amount_to_forward_msat, + skimmed_fee_msat: computed.skimmed_fee_msat, + }); // Update remaining capacity after planning the forward *remaining_capacity -= required_amount; @@ -287,10 +338,9 @@ where if !can_forward { // No existing channel has sufficient capacity, need to open a new channel // Calculate total amount needed for remaining HTLCs (including current one) - let mut total_remaining_amount = required_amount; - for remaining_htlc in &htlcs { - total_remaining_amount += remaining_htlc.expected_outbound_amount_msat(); - } + let total_remaining_amount = computed_htlcs + .iter() + .fold(required_amount, |acc, h| acc.saturating_add(h.amount_to_forward_msat)); return HtlcProcessingActions { forwards, @@ -310,17 +360,18 @@ where for forward_action in actions.forwards { log_debug!( self.logger, - "Executing forward for HTLC {:?} through channel {} with amount {}msat", + "Executing forward for HTLC {:?} through channel {} with amount {}msat (skimmed {}msat)", forward_action.htlc.id(), forward_action.channel_id, - forward_action.htlc.expected_outbound_amount_msat() + forward_action.amount_to_forward_msat, + forward_action.skimmed_fee_msat ); if let Err(e) = self.channel_manager.get_cm().forward_intercepted_htlc( forward_action.htlc.id(), &forward_action.channel_id, forward_action.htlc.next_node_id(), - forward_action.htlc.expected_outbound_amount_msat(), + forward_action.amount_to_forward_msat, ) { log_error!(self.logger, "Failed to forward intercepted HTLC: {:?}", e); } @@ -356,17 +407,18 @@ where log_info!(self.logger, "Calculated actions for peer {}: {:?}", their_node_id, actions); - if actions.forwards.is_empty() && actions.new_channel_needed_msat.is_none() { + if actions.is_empty() { log_debug!(self.logger, "No HTLCs to process for peer {}", their_node_id); return; } - if !actions.forwards.is_empty() { + if actions.total_forward_amount() > 0 { log_debug!( self.logger, - "Processing {} HTLCs for peer {}", + "Processing {} HTLCs for peer {} totaling {}msat", actions.forwards.len(), - their_node_id + their_node_id, + actions.total_forward_amount() ); } From fd716ca9ce61a39525846250efbd14405443f7a7 Mon Sep 17 00:00:00 2001 From: Martin Saposnic Date: Mon, 10 Nov 2025 15:52:31 -0300 Subject: [PATCH 102/135] pass channel count to openChannel on lsps4 --- lightning-liquidity/src/lsps4/event.rs | 2 + lightning-liquidity/src/lsps4/service.rs | 68 +++++++++++++----------- 2 files changed, 40 insertions(+), 30 deletions(-) diff --git a/lightning-liquidity/src/lsps4/event.rs b/lightning-liquidity/src/lsps4/event.rs index 0ac24bc69e6..bb96f43ab3c 100644 --- a/lightning-liquidity/src/lsps4/event.rs +++ b/lightning-liquidity/src/lsps4/event.rs @@ -56,5 +56,7 @@ pub enum LSPS4ServiceEvent { their_network_key: PublicKey, /// The amount we need to forward to the client after opening. amt_to_forward_msat: u64, + /// The number of channels currently open with the peer when the request is made. + channel_count: usize, }, } diff --git a/lightning-liquidity/src/lsps4/service.rs b/lightning-liquidity/src/lsps4/service.rs index fbf8a8e5322..4c059465b3a 100644 --- a/lightning-liquidity/src/lsps4/service.rs +++ b/lightning-liquidity/src/lsps4/service.rs @@ -64,6 +64,7 @@ pub(crate) struct HtlcForwardAction { pub(crate) struct HtlcProcessingActions { pub forwards: Vec, pub new_channel_needed_msat: Option, + pub channel_count: usize, } impl HtlcProcessingActions { @@ -260,7 +261,9 @@ where pub(crate) fn calculate_htlc_actions_for_peer( &self, their_node_id: PublicKey, mut htlcs: Vec, ) -> HtlcProcessingActions { - let channels = self.channel_manager.get_cm().list_channels_with_counterparty(&their_node_id); + let channels = + self.channel_manager.get_cm().list_channels_with_counterparty(&their_node_id); + let channel_count = channels.len(); let mut channel_capacity_map: HashMap = new_hash_map(); for channel in &channels { @@ -275,41 +278,44 @@ where skimmed_fee_msat: u64, } - let mut computed_htlcs: Vec = htlcs.drain(..).map(|htlc| { - let expected_outbound_msat = htlc.expected_outbound_amount_msat(); - if expected_outbound_msat == 0 { - return ComputedHtlc { htlc, amount_to_forward_msat: 0, skimmed_fee_msat: 0 }; - } + let mut computed_htlcs: Vec = htlcs + .drain(..) + .map(|htlc| { + let expected_outbound_msat = htlc.expected_outbound_amount_msat(); + if expected_outbound_msat == 0 { + return ComputedHtlc { htlc, amount_to_forward_msat: 0, skimmed_fee_msat: 0 }; + } - let htlc_id = htlc.id(); - let mut fee_msat = match crate::lsps4::utils::compute_forward_fee( - expected_outbound_msat, - self.config.forwarding_fee_proportional_millionths, - ) { - Some(fee) => core::cmp::min(fee, expected_outbound_msat), - None => { - log_error!( + let htlc_id = htlc.id(); + let mut fee_msat = match crate::lsps4::utils::compute_forward_fee( + expected_outbound_msat, + self.config.forwarding_fee_proportional_millionths, + ) { + Some(fee) => core::cmp::min(fee, expected_outbound_msat), + None => { + log_error!( self.logger, "Overflow while computing skimmed fee for intercepted HTLC {:?}. Skipping skim.", htlc_id ); - 0 - }, - }; + 0 + }, + }; - let mut amount_to_forward_msat = expected_outbound_msat.saturating_sub(fee_msat); - if amount_to_forward_msat == 0 && fee_msat > 0 { - log_error!( - self.logger, - "Skimmed fee equaled the entire HTLC amount for {:?}. Skipping skim.", - htlc_id - ); - fee_msat = 0; - amount_to_forward_msat = expected_outbound_msat; - } + let mut amount_to_forward_msat = expected_outbound_msat.saturating_sub(fee_msat); + if amount_to_forward_msat == 0 && fee_msat > 0 { + log_error!( + self.logger, + "Skimmed fee equaled the entire HTLC amount for {:?}. Skipping skim.", + htlc_id + ); + fee_msat = 0; + amount_to_forward_msat = expected_outbound_msat; + } - ComputedHtlc { htlc, amount_to_forward_msat, skimmed_fee_msat: fee_msat } - }).collect(); + ComputedHtlc { htlc, amount_to_forward_msat, skimmed_fee_msat: fee_msat } + }) + .collect(); let mut forwards = Vec::new(); @@ -345,11 +351,12 @@ where return HtlcProcessingActions { forwards, new_channel_needed_msat: Some(total_remaining_amount), + channel_count, }; } } - HtlcProcessingActions { forwards, new_channel_needed_msat: None } + HtlcProcessingActions { forwards, new_channel_needed_msat: None, channel_count } } /// Execute the actions calculated by calculate_htlc_actions_for_peer @@ -395,6 +402,7 @@ where event_queue_notifier.enqueue(crate::events::LiquidityEvent::LSPS4Service(LSPS4ServiceEvent::OpenChannel { their_network_key: their_node_id, amt_to_forward_msat: channel_size_msat, + channel_count: actions.channel_count, })); } } From 346474110b0217ba8e6234f0b41cf401d070b3b6 Mon Sep 17 00:00:00 2001 From: Martin Saposnic Date: Tue, 18 Nov 2025 15:25:56 -0300 Subject: [PATCH 103/135] add a bunch of logs --- lightning-liquidity/src/lsps4/service.rs | 46 +++++++++++++++- lightning/src/ln/channelmanager.rs | 69 +++++++++++++----------- 2 files changed, 84 insertions(+), 31 deletions(-) diff --git a/lightning-liquidity/src/lsps4/service.rs b/lightning-liquidity/src/lsps4/service.rs index 4c059465b3a..d08c9f3a8ad 100644 --- a/lightning-liquidity/src/lsps4/service.rs +++ b/lightning-liquidity/src/lsps4/service.rs @@ -145,7 +145,21 @@ where &self, intercept_scid: u64, intercept_id: InterceptId, expected_outbound_amount_msat: u64, payment_hash: PaymentHash, ) -> Result<(), APIError> { + log_debug!( + self.logger, + "[htlc_intercepted] HTLC intercepted with scid {}, id {:?}, expected_outbound_amount_msat {}, payment_hash {}", + intercept_scid, + intercept_id, + expected_outbound_amount_msat, + payment_hash + ); if let Some(counterparty_node_id) = self.scid_store.get_peer(intercept_scid) { + log_debug!( + self.logger, + "[htlc_intercepted] Intercept SCID {} matches peer {}, processing HTLC", + intercept_scid, + counterparty_node_id + ); let htlc = InterceptedHtlc::new( intercept_id, intercept_scid, @@ -155,21 +169,51 @@ where ); if !self.is_peer_connected(&counterparty_node_id) { + log_debug!( + self.logger, + "[htlc_intercepted] Peer {} not connected, storing HTLC and sending webhook", + counterparty_node_id + ); self.htlc_store.insert(htlc).unwrap(); // TODO: handle persistence failures let mut event_queue_notifier = self.pending_events.notifier(); event_queue_notifier.enqueue(crate::events::LiquidityEvent::LSPS4Service(LSPS4ServiceEvent::SendWebhook { counterparty_node_id: counterparty_node_id.clone(), })); } else { - let actions = self.calculate_htlc_actions_for_peer(counterparty_node_id,vec![htlc.clone()]); + log_debug!( + self.logger, + "[htlc_intercepted] Peer {} connected, processing HTLC immediately", + counterparty_node_id + ); + let actions = + self.calculate_htlc_actions_for_peer(counterparty_node_id, vec![htlc.clone()]); if actions.new_channel_needed_msat.is_some() { self.htlc_store.insert(htlc).unwrap(); // TODO: handle persistence failures } + log_debug!( + self.logger, + "[htlc_intercepted] Calculated actions for peer {}: {:?}", + counterparty_node_id, + actions + ); + self.execute_htlc_actions(actions, counterparty_node_id.clone()); } + } else { + log_debug!( + self.logger, + "[htlc_intercepted] Intercept SCID {} not present in scid_store; ignoring HTLC id {:?}", + intercept_scid, + intercept_id + ); } + log_debug!( + self.logger, + "[htlc_intercepted] Finalizing htlc_intercepted for intercept_scid {}", + intercept_scid + ); Ok(()) } diff --git a/lightning/src/ln/channelmanager.rs b/lightning/src/ln/channelmanager.rs index dbfe949dd25..8b08fe0c9df 100644 --- a/lightning/src/ln/channelmanager.rs +++ b/lightning/src/ln/channelmanager.rs @@ -6746,6 +6746,9 @@ where &self, intercept_id: InterceptId, next_hop_channel_id: &ChannelId, next_node_id: PublicKey, amt_to_forward_msat: u64, ) -> Result<(), APIError> { + let entry_logger = WithContext::from(&self.logger, Some(next_node_id), Some(*next_hop_channel_id), None); + log_info!(entry_logger, "forward_intercepted_htlc called: intercept_id {:?} next_hop_channel_id {} next_node_id {} amt_to_forward_msat {}", + intercept_id, next_hop_channel_id, next_node_id, amt_to_forward_msat); let _persistence_guard = PersistenceNotifierGuard::notify_on_drop(self); let outbound_scid_alias = { @@ -11339,38 +11342,43 @@ This indicates a bug inside LDK. Please report this error at https://github.com/ // Pull this now to avoid introducing a lock order with `forward_htlcs`. let is_our_scid = self.short_to_chan_info.read().unwrap().contains_key(&scid); - let payment_hash = forward_info.payment_hash; - let logger = WithContext::from( - &self.logger, - None, - Some(prev_channel_id), - Some(payment_hash), + + let payment_hash = forward_info.payment_hash; + let is_intercept = fake_scid::is_valid_intercept(&self.fake_scid_rand_bytes, scid, &self.chain_hash); + let is_phantom = fake_scid::is_valid_phantom(&self.fake_scid_rand_bytes, scid, &self.chain_hash); + let logger = WithContext::from( + &self.logger, + None, + Some(prev_channel_id), + Some(payment_hash), + ); + log_debug!(logger, "Forward HTLC decision: scid {} is_our_scid={} is_intercept={} is_phantom={} incoming_amt_msat={:?} outgoing_amt_msat={} prev_short_channel_id={} prev_channel_id={}", + scid, is_our_scid, is_intercept, is_phantom, forward_info.incoming_amt_msat, forward_info.outgoing_amt_msat, prev_outbound_scid_alias, prev_channel_id); + 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, + }; + let mut fail_intercepted_htlc = |pending_add: PendingAddHTLCInfo| { + let htlc_source = + HTLCSource::PreviousHopData(pending_add.htlc_previous_hop_data()); + let reason = HTLCFailReason::from_failure_code( + LocalHTLCFailureReason::UnknownNextPeer, ); - 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, - }; - let mut fail_intercepted_htlc = |pending_add: PendingAddHTLCInfo| { - let htlc_source = - HTLCSource::PreviousHopData(pending_add.htlc_previous_hop_data()); - let reason = HTLCFailReason::from_failure_code( - LocalHTLCFailureReason::UnknownNextPeer, - ); - let failure_type = HTLCHandlingFailureType::InvalidForward { - requested_forward_scid: scid, - }; - failed_intercept_forwards.push(( - htlc_source, - payment_hash, - reason, - failure_type, - )); + let failure_type = HTLCHandlingFailureType::InvalidForward { + requested_forward_scid: scid, }; + failed_intercept_forwards.push(( + htlc_source, + payment_hash, + reason, + failure_type, + )); + }; // In the case that we have an HTLC that we're supposed to hold onto until the // recipient comes online *and* the outbound scid is encoded as @@ -11465,6 +11473,7 @@ This indicates a bug inside LDK. Please report this error at https://github.com/ } if !new_intercept_events.is_empty() { + log_info!(WithContext::from(&self.logger, None, None, None), "Queueing {} HTLCIntercepted event(s)", new_intercept_events.len()); let mut events = self.pending_events.lock().unwrap(); events.append(&mut new_intercept_events); } From a0b69a210aa0b08614cbca77e173311841408079 Mon Sep 17 00:00:00 2001 From: Fernando Ledesma Date: Wed, 14 Jan 2026 17:13:18 -0500 Subject: [PATCH 104/135] Migrate LSPS4 to async/sync persistence --- lightning-background-processor/src/lib.rs | 2 + lightning-liquidity/src/lsps4/htlc_store.rs | 27 ++- lightning-liquidity/src/lsps4/scid_store.rs | 56 ++++-- lightning-liquidity/src/lsps4/service.rs | 138 +++++++++++--- lightning-liquidity/src/manager.rs | 197 +++++++++++--------- lightning/src/util/persist.rs | 29 +++ 6 files changed, 308 insertions(+), 141 deletions(-) diff --git a/lightning-background-processor/src/lib.rs b/lightning-background-processor/src/lib.rs index abe8fa751f7..445fe3277dc 100644 --- a/lightning-background-processor/src/lib.rs +++ b/lightning-background-processor/src/lib.rs @@ -438,6 +438,8 @@ pub const NO_LIQUIDITY_MANAGER: Option< + Send + Sync, T = &(dyn BroadcasterInterface + Send + Sync), + Logger = dyn lightning::util::logger::Logger + Send + Sync, + L = &(dyn lightning::util::logger::Logger + Send + Sync), > + Send + Sync, >, diff --git a/lightning-liquidity/src/lsps4/htlc_store.rs b/lightning-liquidity/src/lsps4/htlc_store.rs index 434620e94af..bef1584a91e 100644 --- a/lightning-liquidity/src/lsps4/htlc_store.rs +++ b/lightning-liquidity/src/lsps4/htlc_store.rs @@ -8,8 +8,6 @@ use lightning::util::ser::{Readable, Writeable}; use lightning::{impl_writeable_tlv_based, log_error}; use lightning::ln::channelmanager::InterceptId; -use lightning::ln::types::ChannelId; - use lightning::util::logger::Logger; use lightning::util::persist::KVStoreSync; use lightning_types::payment::PaymentHash; @@ -112,15 +110,18 @@ where L::Target: Logger, KV::Target: KVStoreSync { ) -> Result { let mut htlcs = Vec::new(); - for stored_key in kv_store.list( + let stored_keys = kv_store.list( INTERCEPTED_HTLC_STORE_PERSISTENCE_PRIMARY_NAMESPACE, INTERCEPTED_HTLC_STORE_PERSISTENCE_SECONDARY_NAMESPACE, - )? { - let mut reader = Cursor::new(kv_store.read( + )?; + + for stored_key in stored_keys { + let data = kv_store.read( INTERCEPTED_HTLC_STORE_PERSISTENCE_PRIMARY_NAMESPACE, INTERCEPTED_HTLC_STORE_PERSISTENCE_SECONDARY_NAMESPACE, &stored_key, - )?); + )?; + let mut reader = Cursor::new(data); let htlc = InterceptedHtlc::read(&mut reader).map_err(|e| { log_error!(logger, "Failed to deserialize InterceptedHtlc: {}", e); io::Error::new( @@ -138,13 +139,19 @@ where L::Target: Logger, KV::Target: KVStoreSync { } pub(crate) fn insert(&self, htlc: InterceptedHtlc) -> Result { - let mut locked_htlcs = self.htlcs.lock().unwrap(); - - if locked_htlcs.contains_key(&htlc.id()) { - return Ok(false); + // Check if already exists + { + let locked_htlcs = self.htlcs.lock().unwrap(); + if locked_htlcs.contains_key(&htlc.id()) { + return Ok(false); + } } + // Persist first (outside the lock) self.persist(&htlc)?; + + // Then insert into the map + let mut locked_htlcs = self.htlcs.lock().unwrap(); let updated = locked_htlcs.insert(htlc.id(), htlc).is_some(); Ok(updated) } diff --git a/lightning-liquidity/src/lsps4/scid_store.rs b/lightning-liquidity/src/lsps4/scid_store.rs index d339fb7e5af..aeff112ae0a 100644 --- a/lightning-liquidity/src/lsps4/scid_store.rs +++ b/lightning-liquidity/src/lsps4/scid_store.rs @@ -7,12 +7,8 @@ use lightning::util::ser::{Readable, Writeable}; use lightning::{impl_writeable_tlv_based, log_error}; -use lightning::ln::channelmanager::InterceptId; -use lightning::ln::types::ChannelId; - use lightning::util::logger::Logger; use lightning::util::persist::KVStoreSync; -use lightning_types::payment::PaymentHash; use bitcoin::secp256k1::PublicKey; @@ -20,8 +16,7 @@ use lightning::io::{self, Cursor}; use std::collections::HashMap; use std::ops::Deref; -use std::time::{SystemTime, UNIX_EPOCH}; -use crate::sync::{Arc, Mutex, MutexGuard, RwLock}; +use crate::sync::RwLock; use crate::lsps4::utils; @@ -81,15 +76,18 @@ where L::Target: Logger, KV::Target: KVStoreSync { ) -> Result { let mut scids = Vec::new(); - for stored_key in kv_store.list( + let stored_keys = kv_store.list( INTERCEPT_SCID_STORE_PERSISTENCE_PRIMARY_NAMESPACE, INTERCEPT_SCID_STORE_PERSISTENCE_SECONDARY_NAMESPACE, - )? { - let mut reader = Cursor::new(kv_store.read( + )?; + + for stored_key in stored_keys { + let data = kv_store.read( INTERCEPT_SCID_STORE_PERSISTENCE_PRIMARY_NAMESPACE, INTERCEPT_SCID_STORE_PERSISTENCE_SECONDARY_NAMESPACE, &stored_key, - )?); + )?; + let mut reader = Cursor::new(data); let scid = ScidWithPeer::read(&mut reader).map_err(|e| { log_error!(logger, "Failed to deserialize InterceptScid: {}", e); io::Error::new( @@ -110,12 +108,26 @@ where L::Target: Logger, KV::Target: KVStoreSync { } pub(crate) fn insert(&self, scid: ScidWithPeer) -> Result { - let mut locked_peer_by_scid = self.peer_by_scid.write().unwrap(); - let mut locked_scid_by_peer = self.scid_by_peer.write().unwrap(); + use lightning::log_info; + log_info!(self.logger, "[LSPS4 ScidStore] Inserting SCID {} for peer {}", scid.scid(), scid.peer_id()); + // Persist first self.persist(&scid)?; + + // Then insert into the maps + let mut locked_peer_by_scid = self.peer_by_scid.write().unwrap(); + let mut locked_scid_by_peer = self.scid_by_peer.write().unwrap(); let updated = locked_peer_by_scid.insert(scid.scid(), scid.peer_id().clone()).is_some(); locked_scid_by_peer.insert(scid.peer_id().clone(), scid.scid()); + + log_info!( + self.logger, + "[LSPS4 ScidStore] Successfully inserted SCID {} for peer {} (was_update: {})", + scid.scid(), + scid.peer_id(), + updated + ); + Ok(updated) } @@ -170,10 +182,26 @@ where L::Target: Logger, KV::Target: KVStoreSync { } pub fn get_peer(&self, scid: u64) -> Option { - self.peer_by_scid.read().unwrap().get(&scid).cloned() + use lightning::log_debug; + let result = self.peer_by_scid.read().unwrap().get(&scid).cloned(); + log_debug!( + self.logger, + "[LSPS4 ScidStore] get_peer({}) = {:?}", + scid, + result + ); + result } pub fn get_scid(&self, peer_id: &PublicKey) -> Option { - self.scid_by_peer.read().unwrap().get(peer_id).cloned() + use lightning::log_debug; + let result = self.scid_by_peer.read().unwrap().get(peer_id).cloned(); + log_debug!( + self.logger, + "[LSPS4 ScidStore] get_scid({}) = {:?}", + peer_id, + result + ); + result } } \ No newline at end of file diff --git a/lightning-liquidity/src/lsps4/service.rs b/lightning-liquidity/src/lsps4/service.rs index d08c9f3a8ad..20371027c1c 100644 --- a/lightning-liquidity/src/lsps4/service.rs +++ b/lightning-liquidity/src/lsps4/service.rs @@ -31,7 +31,7 @@ use lightning::{log_debug, log_error, log_info}; use lightning::util::errors::APIError; use lightning::util::logger::{Level, Logger}; -use lightning::util::persist::KVStoreSync; +use lightning::util::persist::{KVStore, KVStoreSync}; use lightning_types::payment::PaymentHash; use bitcoin::secp256k1::PublicKey; @@ -50,6 +50,10 @@ use std::vec::Vec; const HTLC_EXPIRY_THRESHOLD_SECS: u64 = 10; +use crate::utils::async_poll::dummy_waker; +use core::task; +use std::future::Future; + /// Action to forward a specific HTLC through a channel #[derive(Debug)] pub(crate) struct HtlcForwardAction { @@ -87,45 +91,43 @@ pub struct LSPS4ServiceConfig { } /// The main object allowing to send and receive LSPS4 messages. -pub struct LSPS4ServiceHandler +pub struct LSPS4ServiceHandler where CM::Target: AChannelManager, - KS::Target: lightning::util::persist::KVStoreSync, + K::Target: KVStore + KVStoreSync, L::Target: Logger, { channel_manager: CM, - kv_store_sync: KS, logger: L, pending_messages: Arc, - pending_events: Arc>>, - scid_store: ScidStore, - htlc_store: HTLCStore, + pending_events: Arc>, + scid_store: ScidStore, + htlc_store: HTLCStore, connected_peers: RwLock>, config: LSPS4ServiceConfig, } -impl LSPS4ServiceHandler +impl LSPS4ServiceHandler where CM::Target: AChannelManager, - KS::Target: lightning::util::persist::KVStoreSync, + K::Target: KVStore + KVStoreSync, L::Target: Logger, { /// Constructs a `LSPS4ServiceHandler`. - pub(crate) fn new( + pub(crate) async fn new( pending_messages: Arc, - pending_events: Arc>>, + pending_events: Arc>, channel_manager: CM, config: LSPS4ServiceConfig, - kv_store_sync: KS, + kv_store: K, logger: L, ) -> Result { Ok(Self { pending_messages, pending_events, - scid_store: ScidStore::new(kv_store_sync.clone(), logger.clone())?, - htlc_store: HTLCStore::new(kv_store_sync.clone(), logger.clone())?, + scid_store: ScidStore::new(kv_store.clone(), logger.clone())?, + htlc_store: HTLCStore::new(kv_store.clone(), logger.clone())?, channel_manager, - kv_store_sync, config, logger, connected_peers: RwLock::new(HashSet::new()), @@ -145,18 +147,18 @@ where &self, intercept_scid: u64, intercept_id: InterceptId, expected_outbound_amount_msat: u64, payment_hash: PaymentHash, ) -> Result<(), APIError> { - log_debug!( + log_info!( self.logger, - "[htlc_intercepted] HTLC intercepted with scid {}, id {:?}, expected_outbound_amount_msat {}, payment_hash {}", + "[LSPS4] HTLC intercepted - SCID: {}, intercept_id: {:?}, amount: {} msat, payment_hash: {}", intercept_scid, intercept_id, expected_outbound_amount_msat, payment_hash ); if let Some(counterparty_node_id) = self.scid_store.get_peer(intercept_scid) { - log_debug!( + log_info!( self.logger, - "[htlc_intercepted] Intercept SCID {} matches peer {}, processing HTLC", + "[LSPS4] SCID {} matched to peer {}, processing HTLC", intercept_scid, counterparty_node_id ); @@ -202,9 +204,9 @@ where self.execute_htlc_actions(actions, counterparty_node_id.clone()); } } else { - log_debug!( + log_error!( self.logger, - "[htlc_intercepted] Intercept SCID {} not present in scid_store; ignoring HTLC id {:?}", + "[LSPS4] SCID {} NOT FOUND in scid_store! Cannot route HTLC {:?}. Payment will fail.", intercept_scid, intercept_id ); @@ -237,13 +239,22 @@ where /// Will attempt to forward any pending intercepted htlcs to this counterparty. pub fn peer_connected(&self, counterparty_node_id: PublicKey) { - log_info!(self.logger, "Peer connected: {} inserting into connected peers map", counterparty_node_id); + log_info!( + self.logger, + "Peer connected: {} inserting into connected peers map", + counterparty_node_id + ); self.connected_peers.write().unwrap().insert(counterparty_node_id); let htlcs = self.htlc_store.get_htlcs_by_node_id(&counterparty_node_id); - log_info!(self.logger, "{} has {} htlcs waiting to be forwarded", counterparty_node_id, htlcs.len()); + log_info!( + self.logger, + "{} has {} htlcs waiting to be forwarded", + counterparty_node_id, + htlcs.len() + ); self.process_htlcs_for_peer(counterparty_node_id.clone(), htlcs); } @@ -252,7 +263,7 @@ where /// /// Will fail the HTLCs and remove them from the store. /// Needs to be called regularly to cleanup old htlcs. - pub fn handle_expired_htlcs(&self, now: u64) { + pub async fn handle_expired_htlcs(&self, now: u64) { for htlc in self.htlc_store.get_expired_htlcs(now, HTLC_EXPIRY_THRESHOLD_SECS) { if let Err(e) = self.channel_manager.get_cm().fail_intercepted_htlc(htlc.id()) { log_error!( @@ -284,19 +295,75 @@ where fn handle_register_node_request( &self, request_id: LSPSRequestId, counterparty_node_id: &PublicKey, _params: RegisterNodeRequest, ) -> Result<(), LightningError> { + log_info!( + self.logger, + "[LSPS4] Received RegisterNodeRequest from {} with request_id {:?}", + counterparty_node_id, + request_id + ); + let intercept_scid = match self.scid_store.get_scid(counterparty_node_id) { - Some(intercept_scid) => intercept_scid, + Some(intercept_scid) => { + log_info!( + self.logger, + "[LSPS4] Found existing intercept SCID {} for peer {}", + intercept_scid, + counterparty_node_id + ); + intercept_scid + }, None => { let intercept_scid = self.channel_manager.get_cm().get_intercept_scid(); - self.scid_store.add_intercepted_scid(intercept_scid, counterparty_node_id.clone()).unwrap(); + log_info!( + self.logger, + "[LSPS4] Generated NEW intercept SCID {} for peer {}", + intercept_scid, + counterparty_node_id + ); + self.scid_store.add_intercepted_scid(intercept_scid, counterparty_node_id.clone()) + .map_err(|e| { + log_error!( + self.logger, + "[LSPS4] Failed to persist intercept SCID {} for peer {}: {}", + intercept_scid, + counterparty_node_id, + e + ); + LightningError { + err: format!("Failed to add intercepted SCID: {}", e), + action: ErrorAction::IgnoreAndLog(Level::Error), + } + })?; + log_info!( + self.logger, + "[LSPS4] Successfully stored intercept SCID {} for peer {}", + intercept_scid, + counterparty_node_id + ); intercept_scid } }; + + log_info!( + self.logger, + "[LSPS4] Sending RegisterNodeResponse to {} with SCID {} and CLTV delta {}", + counterparty_node_id, + intercept_scid, + self.config.cltv_expiry_delta + ); + let mut message_queue_notifier = self.pending_messages.notifier(); message_queue_notifier.enqueue(counterparty_node_id, LSPS4Message::Response(request_id, LSPS4Response::RegisterNode(RegisterNodeResponse { jit_channel_scid: intercept_scid.into(), lsp_cltv_expiry_delta: self.config.cltv_expiry_delta, })).into()); + + log_info!( + self.logger, + "[LSPS4] RegisterNodeResponse enqueued successfully for peer {}", + counterparty_node_id + ); + Ok(()) } @@ -477,12 +544,27 @@ where self.execute_htlc_actions(actions, their_node_id); } + /// Persists the state of the service handler towards the given [`KVStore`] implementation. + /// + /// This will be regularly called by LDK's background processor if necessary and only needs to + /// be called manually if it's not utilized. + /// + /// Note: LSPS4 stores (ScidStore and HTLCStore) persist immediately when operations occur, + /// so this method is primarily for consistency with the interface. It may be used for + /// future cleanup or pruning operations. + pub(crate) fn persist(&self) -> Result<(), lightning::io::Error> { + // LSPS4 stores persist immediately when operations occur, so there's no batched + // persistence needed. This method exists for interface consistency and potential + // future use cases (e.g., cleanup, pruning, etc.). + Ok(()) + } + } -impl LSPSProtocolMessageHandler for LSPS4ServiceHandler +impl LSPSProtocolMessageHandler for LSPS4ServiceHandler where CM::Target: AChannelManager, - KV::Target: KVStoreSync, + K::Target: KVStore + KVStoreSync, L::Target: Logger, { type ProtocolMessage = LSPS4Message; diff --git a/lightning-liquidity/src/manager.rs b/lightning-liquidity/src/manager.rs index c4ab6c66788..23c7d772a06 100644 --- a/lightning-liquidity/src/manager.rs +++ b/lightning-liquidity/src/manager.rs @@ -127,7 +127,7 @@ pub trait ALiquidityManager { /// A type that may be dereferenced to [`Self::Filter`]. type C: Deref + Clone; /// A type implementing [`KVStore`]. - type KVStore: KVStore + ?Sized; + type KVStore: KVStore + KVStoreSync + ?Sized; /// A type that may be dereferenced to [`Self::KVStore`]. type K: Deref + Clone; /// A type implementing [`TimeProvider`]. @@ -138,10 +138,14 @@ pub trait ALiquidityManager { type BroadcasterInterface: BroadcasterInterface + ?Sized; /// A type that may be dereferenced to [`Self::BroadcasterInterface`]. type T: Deref + Clone; + /// A type implementing [`Logger`]. + type Logger: Logger + ?Sized; + /// A type that may be dereferenced to [`Self::Logger`]. + type L: Deref + Clone; /// Returns a reference to the actual [`LiquidityManager`] object. fn get_lm( &self, - ) -> &LiquidityManager; + ) -> &LiquidityManager; } impl< @@ -152,15 +156,17 @@ impl< K: Deref + Clone, TP: Deref + Clone, T: Deref + Clone, - > ALiquidityManager for LiquidityManager + L: Deref + Clone, + > ALiquidityManager for LiquidityManager where ES::Target: EntropySource, NS::Target: NodeSigner, CM::Target: AChannelManager, C::Target: Filter, - K::Target: KVStore, + K::Target: KVStore + KVStoreSync, TP::Target: TimeProvider, T::Target: BroadcasterInterface, + L::Target: Logger, { type EntropySource = ES::Target; type ES = ES; @@ -176,7 +182,9 @@ where type TP = TP; type BroadcasterInterface = T::Target; type T = T; - fn get_lm(&self) -> &LiquidityManager { + type Logger = L::Target; + type L = L; + fn get_lm(&self) -> &LiquidityManager { self } } @@ -230,6 +238,7 @@ pub trait ALiquidityManagerSync { KVStoreSyncWrapper, Self::TP, Self::T, + Self::L, >; /// Returns a reference to the actual [`LiquidityManager`] object. fn get_lm( @@ -285,6 +294,7 @@ where KVStoreSyncWrapper, Self::TP, Self::T, + Self::L, > { &self.inner } @@ -320,14 +330,16 @@ pub struct LiquidityManager< K: Deref + Clone, TP: Deref + Clone, T: Deref + Clone, + L: Deref + Clone > where ES::Target: EntropySource, NS::Target: NodeSigner, CM::Target: AChannelManager, C::Target: Filter, - K::Target: KVStore, + K::Target: KVStore + KVStoreSync, TP::Target: TimeProvider, T::Target: BroadcasterInterface, + L::Target: Logger, { pending_messages: Arc, pending_events: Arc>, @@ -342,6 +354,7 @@ pub struct LiquidityManager< lsps2_service_handler: Option>, lsps2_client_handler: Option>, lsps4_client_handler: Option>, + lsps4_service_handler: Option>, lsps5_service_handler: Option>, lsps5_client_handler: Option>, service_config: Option, @@ -359,23 +372,28 @@ impl< C: Deref + Clone, K: Deref + Clone, T: Deref + Clone, - > LiquidityManager + L: Deref + Clone, + > LiquidityManager where ES::Target: EntropySource, NS::Target: NodeSigner, CM::Target: AChannelManager, C::Target: Filter, - K::Target: KVStore, + K::Target: KVStore + KVStoreSync, T::Target: BroadcasterInterface, + L::Target: Logger, { /// Constructor for the [`LiquidityManager`] using the default system clock /// /// Will read persisted service states from the given [`KVStore`]. + /// + /// `logger` is required when LSPS4 service is configured, otherwise it can be `None`. 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, client_config: Option, + logger: L, ) -> Result { Self::new_with_custom_time_provider( entropy_source, @@ -388,6 +406,7 @@ where service_config, client_config, DefaultTimeProvider, + logger, ) .await } @@ -401,15 +420,17 @@ impl< K: Deref + Clone, TP: Deref + Clone, T: Deref + Clone, - > LiquidityManager + L: Deref + Clone, + > LiquidityManager where ES::Target: EntropySource, NS::Target: NodeSigner, CM::Target: AChannelManager, C::Target: Filter, - K::Target: KVStore, + K::Target: KVStore + KVStoreSync, TP::Target: TimeProvider, T::Target: BroadcasterInterface, + L::Target: Logger, { /// Constructor for the [`LiquidityManager`] with a custom time provider. /// @@ -419,11 +440,14 @@ where /// available. /// Sets up the required protocol message handlers based on the given /// [`LiquidityClientConfig`] and [`LiquidityServiceConfig`]. + /// + /// `logger` is required when LSPS4 service is configured, otherwise it can be `None`. 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, client_config: Option, time_provider: TP, + logger: L, ) -> Result { let pending_msgs_or_needs_persist_notifier = Arc::new(Notifier::new()); let pending_messages = @@ -522,6 +546,29 @@ where None }; + let lsps4_service_handler = if let Some(service_config) = service_config.as_ref() { + if let Some(lsps4_service_config) = service_config.lsps4_service_config.as_ref() { + if let Some(number) = + as LSPSProtocolMessageHandler>::PROTOCOL_NUMBER + { + supported_protocols.push(number); + } + + Some(LSPS4ServiceHandler::new( + Arc::clone(&pending_messages), + Arc::clone(&pending_events), + channel_manager.clone(), + lsps4_service_config.clone(), + kv_store.clone(), + logger.clone(), + ).await?) + } else { + None + } + } else { + None + }; + let lsps1_client_handler = client_config.as_ref().and_then(|config| { config.lsps1_client_config.as_ref().map(|config| { LSPS1ClientHandler::new( @@ -577,6 +624,7 @@ where lsps2_client_handler, lsps2_service_handler, lsps4_client_handler, + lsps4_service_handler, lsps5_client_handler, lsps5_service_handler, service_config, @@ -636,6 +684,13 @@ where self.lsps4_client_handler.as_ref() } + /// Returns a reference to the LSPS4 server-side handler. + /// + /// The returned handler allows to initiate the LSPS4 service-side flow. + pub fn lsps4_service_handler(&self) -> Option<&LSPS4ServiceHandler> { + self.lsps4_service_handler.as_ref() + } + /// Returns a reference to the LSPS5 client-side handler. /// @@ -724,6 +779,10 @@ where did_persist |= lsps2_service_handler.persist().await?; } + if let Some(lsps4_service_handler) = self.lsps4_service_handler.as_ref() { + lsps4_service_handler.persist()?; + } + if let Some(lsps5_service_handler) = self.lsps5_service_handler.as_ref() { did_persist |= lsps5_service_handler.persist().await?; } @@ -804,9 +863,15 @@ where }, } }, - LSPSMessage::LSPS4(LSPS4Message::Request(..)) => { - // LSPS4 service handler is only available in LiquidityManagerSync - return Err(LightningError { err: format!("Received LSPS4 request message without LSPS4 service handler configured. From node = {:?}", sender_node_id), action: ErrorAction::IgnoreAndLog(Level::Info)}); + LSPSMessage::LSPS4(msg @ LSPS4Message::Request(..)) => { + match &self.lsps4_service_handler { + Some(lsps4_service_handler) => { + lsps4_service_handler.handle_message(msg, sender_node_id)?; + }, + None => { + return Err(LightningError { err: format!("Received LSPS4 request message without LSPS4 service handler configured. From node = {:?}", sender_node_id), action: ErrorAction::IgnoreAndLog(Level::Info)}); + }, + } }, LSPSMessage::LSPS5(msg @ LSPS5Message::Response(..)) => { match &self.lsps5_client_handler { @@ -864,15 +929,17 @@ impl< K: Deref + Clone, TP: Deref + Clone, T: Deref + Clone, - > CustomMessageReader for LiquidityManager + L: Deref + Clone + > CustomMessageReader for LiquidityManager where ES::Target: EntropySource, NS::Target: NodeSigner, CM::Target: AChannelManager, C::Target: Filter, - K::Target: KVStore, + K::Target: KVStore + KVStoreSync, TP::Target: TimeProvider, T::Target: BroadcasterInterface, + L::Target: Logger, { type CustomMessage = RawLSPSMessage; @@ -896,15 +963,17 @@ impl< K: Deref + Clone, TP: Deref + Clone, T: Deref + Clone, - > CustomMessageHandler for LiquidityManager + L: Deref + Clone + > CustomMessageHandler for LiquidityManager where ES::Target: EntropySource, NS::Target: NodeSigner, CM::Target: AChannelManager, C::Target: Filter, - K::Target: KVStore, + K::Target: KVStore + KVStoreSync, TP::Target: TimeProvider, T::Target: BroadcasterInterface, + L::Target: Logger, { fn handle_custom_message( &self, msg: Self::CustomMessage, sender_node_id: PublicKey, @@ -1006,7 +1075,9 @@ where lsps2_service_handler.peer_disconnected(counterparty_node_id); } - // LSPS4 service handler is only available in LiquidityManagerSync + if let Some(lsps4_service_handler) = self.lsps4_service_handler.as_ref() { + lsps4_service_handler.peer_disconnected(&counterparty_node_id); + } if let Some(lsps5_service_handler) = self.lsps5_service_handler.as_ref() { lsps5_service_handler.peer_disconnected(&counterparty_node_id); @@ -1016,8 +1087,9 @@ where &self, counterparty_node_id: bitcoin::secp256k1::PublicKey, _: &lightning::ln::msgs::Init, _: bool, ) -> Result<(), ()> { - - // LSPS4 service handler is only available in LiquidityManagerSync + if let Some(lsps4_service_handler) = self.lsps4_service_handler.as_ref() { + lsps4_service_handler.peer_connected(counterparty_node_id); + } if let Some(lsps5_service_handler) = self.lsps5_service_handler.as_ref() { lsps5_service_handler.peer_connected(&counterparty_node_id); @@ -1035,15 +1107,17 @@ impl< K: Deref + Clone, TP: Deref + Clone, T: Deref + Clone, - > Listen for LiquidityManager + L: Deref + Clone + > Listen for LiquidityManager where ES::Target: EntropySource, NS::Target: NodeSigner, CM::Target: AChannelManager, C::Target: Filter, - K::Target: KVStore, + K::Target: KVStore + KVStoreSync, TP::Target: TimeProvider, T::Target: BroadcasterInterface, + L::Target: Logger, { fn filtered_block_connected( &self, header: &bitcoin::block::Header, txdata: &chain::transaction::TransactionData, @@ -1081,15 +1155,17 @@ impl< K: Deref + Clone, TP: Deref + Clone, T: Deref + Clone, - > Confirm for LiquidityManager + L: Deref + Clone + > Confirm for LiquidityManager where ES::Target: EntropySource, NS::Target: NodeSigner, CM::Target: AChannelManager, C::Target: Filter, - K::Target: KVStore, + K::Target: KVStore + KVStoreSync, TP::Target: TimeProvider, T::Target: BroadcasterInterface, + L::Target: Logger, { fn transactions_confirmed( &self, _header: &bitcoin::block::Header, _txdata: &chain::transaction::TransactionData, @@ -1138,8 +1214,7 @@ pub struct LiquidityManagerSync< T::Target: BroadcasterInterface, L::Target: Logger, { - inner: LiquidityManager, TP, T>, - lsps4_service_handler: Option>, + inner: LiquidityManager, TP, T, L>, } #[cfg(feature = "time")] @@ -1183,6 +1258,7 @@ where transaction_broadcaster, service_config.clone(), client_config, + logger, )); let mut waker = dummy_waker(); @@ -1195,25 +1271,8 @@ where }, }?; - // Create LSPS4 service handler with sync store if service is configured - let lsps4_service_handler = if let Some(ref config) = service_config { - if config.lsps4_service_config.is_some() { - Some(LSPS4ServiceHandler::new( - inner.pending_messages.clone(), - inner.pending_events.clone(), - channel_manager, - config.lsps4_service_config.clone().unwrap(), - kv_store_sync, - logger, - )?) - } else { - None - } - } else { - None - }; - Ok(Self { inner, lsps4_service_handler }) + Ok(Self { inner }) } } @@ -1259,6 +1318,7 @@ where service_config.clone(), client_config, time_provider, + logger, )); let mut waker = dummy_waker(); @@ -1271,25 +1331,8 @@ where }, }?; - // Create LSPS4 service handler with sync store if service is configured - let lsps4_service_handler = if let Some(ref config) = service_config { - if config.lsps4_service_config.is_some() { - Some(LSPS4ServiceHandler::new( - inner.pending_messages.clone(), - inner.pending_events.clone(), - channel_manager, - config.lsps4_service_config.clone().unwrap(), - kv_store_sync, - logger, - )?) - } else { - None - } - } else { - None - }; - Ok(Self { inner, lsps4_service_handler }) + Ok(Self { inner }) } /// Returns a reference to the LSPS0 client-side handler. @@ -1350,9 +1393,9 @@ where /// Returns a reference to the LSPS4 server-side handler. /// - /// The returned hendler allows to initiate the LSPS4 service-side flow. - pub fn lsps4_service_handler(&self) -> Option<&LSPS4ServiceHandler> { - self.lsps4_service_handler.as_ref() + /// Wraps [`LiquidityManager::lsps4_service_handler`]. + pub fn lsps4_service_handler(&self) -> Option<&LSPS4ServiceHandler, L>> { + self.inner.lsps4_service_handler() } /// Returns a reference to the LSPS5 client-side handler. @@ -1477,20 +1520,6 @@ where fn handle_custom_message( &self, msg: Self::CustomMessage, sender_node_id: PublicKey, ) -> Result<(), lightning::ln::msgs::LightningError> { - // Parse the message first to see if it's an LSPS4 service request - if let Some(ref handler) = self.lsps4_service_handler { - let parsed_msg = { - let mut request_id_to_method_map = self.inner.request_id_to_method_map.lock().unwrap(); - LSPSMessage::from_str_with_id_map(&msg.payload, &mut request_id_to_method_map) - }; - - if let Ok(LSPSMessage::LSPS4(lsps4_msg @ LSPS4Message::Request(..))) = parsed_msg { - // Handle LSPS4 service requests with our sync handler - return handler.handle_message(lsps4_msg, &sender_node_id); - } - } - - // Delegate everything else to the inner manager self.inner.handle_custom_message(msg, sender_node_id) } @@ -1507,22 +1536,12 @@ where } fn peer_disconnected(&self, counterparty_node_id: bitcoin::secp256k1::PublicKey) { - // Notify LSPS4 service handler if present - if let Some(ref handler) = self.lsps4_service_handler { - handler.peer_disconnected(&counterparty_node_id); - } - self.inner.peer_disconnected(counterparty_node_id) } fn peer_connected( &self, counterparty_node_id: bitcoin::secp256k1::PublicKey, init_msg: &lightning::ln::msgs::Init, inbound: bool, ) -> Result<(), ()> { - // Notify LSPS4 service handler if present - if let Some(ref handler) = self.lsps4_service_handler { - handler.peer_connected(counterparty_node_id); - } - self.inner.peer_connected(counterparty_node_id, init_msg, inbound) } } diff --git a/lightning/src/util/persist.rs b/lightning/src/util/persist.rs index d00e29e686a..43bd55c7fd7 100644 --- a/lightning/src/util/persist.rs +++ b/lightning/src/util/persist.rs @@ -247,6 +247,35 @@ where } } +impl KVStoreSync for KVStoreSyncWrapper +where + K::Target: KVStoreSync, +{ + fn read( + &self, primary_namespace: &str, secondary_namespace: &str, key: &str, + ) -> io::Result> { + self.0.read(primary_namespace, secondary_namespace, key) + } + + fn write( + &self, primary_namespace: &str, secondary_namespace: &str, key: &str, buf: Vec, + ) -> io::Result<()> { + self.0.write(primary_namespace, secondary_namespace, key, buf.to_vec()) + } + + fn remove( + &self, primary_namespace: &str, secondary_namespace: &str, key: &str, lazy: bool, + ) -> io::Result<()> { + self.0.remove(primary_namespace, secondary_namespace, key, lazy) + } + + fn list( + &self, primary_namespace: &str, secondary_namespace: &str, + ) -> io::Result> { + self.0.list(primary_namespace, secondary_namespace) + } +} + /// Provides an interface that allows storage and retrieval of persisted values that are associated /// with given keys. /// From 1a1c645d77510c5fa1130c021a8b9a5c5340ef16 Mon Sep 17 00:00:00 2001 From: Austin Mackillop Date: Thu, 15 Jan 2026 14:52:10 -0500 Subject: [PATCH 105/135] Make minimum channel reserve configurable (#1) Add configurable min_their_channel_reserve_satoshis field to ChannelHandshakeConfig, allowing users to set the minimum channel reserve value. Special case: When set to 0, the dust limit check is bypassed. This enables LSP use cases where clients are able to fully withdraw their funds from the channel without closing it. For non-zero values below the dust limit, validation still enforces the dust limit. Replaces hardcoded MIN_THEIR_CHAN_RESERVE_SATOSHIS constant with configurable value while maintaining backward compatibility. Default remains 1000 sats to preserve existing behavior. --- lightning/src/ln/channel.rs | 102 +++++++++++++++++++++++++++++------ lightning/src/util/config.rs | 34 ++++++++++-- 2 files changed, 116 insertions(+), 20 deletions(-) diff --git a/lightning/src/ln/channel.rs b/lightning/src/ln/channel.rs index a89e240edc1..08b0249d8e7 100644 --- a/lightning/src/ln/channel.rs +++ b/lightning/src/ln/channel.rs @@ -910,6 +910,10 @@ pub const MAX_CHAN_DUST_LIMIT_SATOSHIS: u64 = MAX_STD_OUTPUT_DUST_LIMIT_SATOSHIS pub const MIN_CHAN_DUST_LIMIT_SATOSHIS: u64 = 354; // Just a reasonable implementation-specific safe lower bound, higher than the dust limit. +// Deprecated: This constant is kept for backward compatibility. +// The minimum channel reserve is now configurable via `ChannelHandshakeConfig::min_their_channel_reserve_satoshis`. +// This constant retains its original value for API compatibility, but the actual behavior uses the config value. +#[allow(dead_code)] pub const MIN_THEIR_CHAN_RESERVE_SATOSHIS: u64 = 1000; /// Used to return a simple Error back to ChannelManager. Will get converted to a @@ -3489,9 +3493,10 @@ where } } - 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` + // Allow bypassing dust limit when min_their_channel_reserve_satoshis is explicitly set to 0 (LSP use case) + if holder_selected_channel_reserve_satoshis < MIN_CHAN_DUST_LIMIT_SATOSHIS + && config.channel_handshake_config.min_their_channel_reserve_satoshis > 0 { + // Protocol level safety check in place 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 { @@ -3501,7 +3506,9 @@ where 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 { + // Allow bypassing dust limit when min_their_channel_reserve_satoshis is explicitly set to 0 (LSP use case) + if holder_selected_channel_reserve_satoshis < open_channel_fields.dust_limit_satoshis + && config.channel_handshake_config.min_their_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))); } @@ -4214,7 +4221,9 @@ where 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 { + // Allow bypassing dust limit when holder_selected_channel_reserve_satoshis is 0 (LSP use case) + 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 { @@ -6474,14 +6483,16 @@ 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 `ChannelHandshakeConfig::min_their_channel_reserve_satoshis`. /// /// Returns `Err` if `channel_value_satoshis` is smaller than -/// `MIN_THEIR_CHAN_RESERVE_SATOSHIS`. +/// `ChannelHandshakeConfig::min_their_channel_reserve_satoshis`. pub(crate) fn get_holder_selected_channel_reserve_satoshis( channel_value_satoshis: u64, config: &UserConfig, ) -> Result { - if channel_value_satoshis < MIN_THEIR_CHAN_RESERVE_SATOSHIS { + let min_their_channel_reserve_satoshis = + config.channel_handshake_config.min_their_channel_reserve_satoshis; + if channel_value_satoshis < min_their_channel_reserve_satoshis { return Err(()); } // As described in the `ChannelHandshakeConfig` docs, we cap this value at 1_000_000. @@ -6491,7 +6502,7 @@ pub(crate) fn get_holder_selected_channel_reserve_satoshis( ); let calculated_reserve = channel_value_satoshis.saturating_mul(counterparty_chan_reserve_prop_mil) / 1_000_000; - Ok(cmp::max(calculated_reserve, MIN_THEIR_CHAN_RESERVE_SATOSHIS)) + Ok(cmp::max(calculated_reserve, min_their_channel_reserve_satoshis)) } /// This is for legacy reasons, present for forward-compatibility. @@ -13446,10 +13457,11 @@ where L::Target: Logger, { let holder_selected_channel_reserve_satoshis = get_holder_selected_channel_reserve_satoshis(channel_value_satoshis, config) - .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 { - // Protocol level safety check in place, although it should never happen because - // of `MIN_THEIR_CHAN_RESERVE_SATOSHIS` + .map_err(|()| APIError::APIMisuseError { err: format!("The channel value {channel_value_satoshis} is smaller than {}", config.channel_handshake_config.min_their_channel_reserve_satoshis)})?; + // Allow bypassing dust limit when min_their_channel_reserve_satoshis is explicitly set to 0 (LSP use case) + if holder_selected_channel_reserve_satoshis < MIN_CHAN_DUST_LIMIT_SATOSHIS + && config.channel_handshake_config.min_their_channel_reserve_satoshis > 0 { + // Protocol level safety check in place return Err(APIError::APIMisuseError { err: format!("Holder selected channel reserve below \ implemention limit dust_limit_satoshis {}", holder_selected_channel_reserve_satoshis) }); } @@ -13819,7 +13831,7 @@ where 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) - .map_err(|()| ChannelError::close(format!("The channel value {} is smaller than {MIN_THEIR_CHAN_RESERVE_SATOSHIS}", msg.common_fields.funding_satoshis)))?; + .map_err(|()| ChannelError::close(format!("The channel value {} is smaller than {}", msg.common_fields.funding_satoshis, config.channel_handshake_config.min_their_channel_reserve_satoshis)))?; let counterparty_pubkeys = ChannelPublicKeys { funding_pubkey: msg.common_fields.funding_pubkey, revocation_basepoint: RevocationBasepoint::from(msg.common_fields.revocation_basepoint), @@ -16266,7 +16278,7 @@ mod tests { test_self_and_counterparty_channel_reserve(10_000_000, 0.60, 0.30); // Test with calculated channel reserve less than lower bound - // i.e `MIN_THEIR_CHAN_RESERVE_SATOSHIS` + // i.e `ChannelHandshakeConfig::min_their_channel_reserve_satoshis` test_self_and_counterparty_channel_reserve(100_000, 0.00002, 0.30); // Test with invalid channel reserves since sum of both is greater than or equal @@ -16308,7 +16320,7 @@ mod tests { 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); + let expected_outbound_selected_chan_reserve = cmp::max(outbound_node_config.channel_handshake_config.min_their_channel_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(); @@ -16318,7 +16330,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, /*is_0conf=*/false).unwrap(); - 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); + let expected_inbound_selected_chan_reserve = cmp::max(inbound_node_config.channel_handshake_config.min_their_channel_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); @@ -16329,6 +16341,62 @@ mod tests { } } + #[test] + #[rustfmt::skip] + fn test_configurable_min_channel_reserve() { + let test_est = TestFeeEstimator::new(15000); + let fee_est = LowerBoundedFeeEstimator::new(&test_est); + let logger = test_utils::TestLogger::new(); + let secp_ctx = Secp256k1::new(); + let keys_provider = test_utils::TestKeysInterface::new(&[42; 32], Network::Testnet); + let outbound_node_id = PublicKey::from_secret_key(&secp_ctx, &SecretKey::from_slice(&[42; 32]).unwrap()); + + // Test with min_their_channel_reserve_satoshis set to 0 (LSP use case) + let mut config = UserConfig::default(); + config.channel_handshake_config.min_their_channel_reserve_satoshis = 0; + config.channel_handshake_config.their_channel_reserve_proportional_millionths = 0; + + let chan = OutboundV1Channel::<&TestKeysInterface>::new( + &fee_est, &&keys_provider, &&keys_provider, outbound_node_id, + &channelmanager::provided_init_features(&config), + 1_000_000, 100_000, 42, &config, 0, 42, None, &logger + ).unwrap(); + + // With 0 minimum and 0 proportional, reserve should be 0 (bypasses dust limit) + assert_eq!(chan.funding.holder_selected_channel_reserve_satoshis, 0); + + // Test with custom minimum enforced when proportional is lower + config.channel_handshake_config.min_their_channel_reserve_satoshis = 10_000; + config.channel_handshake_config.their_channel_reserve_proportional_millionths = 10_000; // 1% + + let chan_small = OutboundV1Channel::<&TestKeysInterface>::new( + &fee_est, &&keys_provider, &&keys_provider, outbound_node_id, + &channelmanager::provided_init_features(&config), + 100_000, 100_000, 42, &config, 0, 42, None, &logger + ).unwrap(); + + // Proportional would be 1% of 100k = 1000, but minimum is 10000, so 10000 should be used + assert_eq!(chan_small.funding.holder_selected_channel_reserve_satoshis, 10_000); + + // Test that dust limit is still enforced when min_their_channel_reserve_satoshis is non-zero but below dust limit + config.channel_handshake_config.min_their_channel_reserve_satoshis = 100; // Below dust limit of 354 + config.channel_handshake_config.their_channel_reserve_proportional_millionths = 0; + + let result = OutboundV1Channel::<&TestKeysInterface>::new( + &fee_est, &&keys_provider, &&keys_provider, outbound_node_id, + &channelmanager::provided_init_features(&config), + 1_000_000, 100_000, 42, &config, 0, 42, None, &logger + ); + + // Should fail because 100 < 354 (dust limit) and min_their_channel_reserve_satoshis > 0 + assert!(result.is_err()); + if let Err(APIError::APIMisuseError { err }) = result { + assert!(err.contains("dust_limit_satoshis")); + } else { + panic!("Expected APIMisuseError"); + } + } + #[test] #[rustfmt::skip] fn channel_update() { diff --git a/lightning/src/util/config.rs b/lightning/src/util/config.rs index 0856dc96394..df7d318b015 100644 --- a/lightning/src/util/config.rs +++ b/lightning/src/util/config.rs @@ -154,14 +154,40 @@ pub struct ChannelHandshakeConfig { /// /// Default value: `10_000` millionths (i.e., 1% of channel value) /// - /// Minimum value: If the calculated proportional value is less than `1000` sats, it will be - /// treated as `1000` sats instead, which is a safe implementation-specific lower - /// bound. + /// Minimum value: If the calculated proportional value is less than `min_their_channel_reserve_satoshis`, + /// it will be treated as `min_their_channel_reserve_satoshis` instead. /// /// Maximum value: `1_000_000` (i.e., 100% of channel value. Any values larger than one million /// will be treated as one million instead, although channel negotiations will /// fail in that case.) pub their_channel_reserve_proportional_millionths: u32, + /// The minimum absolute channel reserve value in satoshis that will be enforced regardless of + /// the proportional reserve calculation. + /// + /// This ensures that even if the proportional reserve calculation results in a very small value + /// (or zero), at least this minimum amount will be required as a channel reserve. This provides + /// a safety mechanism to ensure some minimum reserve is always maintained. + /// + /// **Special case: Setting to `0`** + /// + /// Setting this value to `0` allows the counterparty to have no channel reserve, enabling them + /// to use their entire channel balance for payments. This is useful for LSP use cases where the + /// LSP wants to allow clients to be able to fully withdraw their funds from the channel without + /// closing it. + /// + /// **Security Warning:** + /// + /// When set to `0`, the channel reserve no longer provides economic security. If the counterparty + /// broadcasts a revoked state, there is no reserve to claim as punishment. This removes the + /// economic disincentive for the counterparty to attempt cheating. Only use this setting with + /// trusted counterparties (e.g., known LSP clients) or when other trust mechanisms are in place. + /// + /// When set to `0`, the dust limit check is bypassed, allowing reserves below the protocol + /// minimum dust limit (354 sats). For any non-zero value below the dust limit, the dust limit + /// check will still be enforced. + /// + /// Default value: `1000` sats + pub min_their_channel_reserve_satoshis: u64, /// If set, we attempt to negotiate the `anchors_zero_fee_htlc_tx`option for all future /// channels. This feature requires having a reserve of onchain funds readily available to bump /// transactions in the event of a channel force close to avoid the possibility of losing funds. @@ -254,6 +280,7 @@ impl Default for ChannelHandshakeConfig { announce_for_forwarding: false, commit_upfront_shutdown_pubkey: true, their_channel_reserve_proportional_millionths: 10_000, + min_their_channel_reserve_satoshis: 1_000, negotiate_anchors_zero_fee_htlc_tx: false, negotiate_anchor_zero_fee_commitments: false, our_max_accepted_htlcs: 50, @@ -276,6 +303,7 @@ impl Readable for ChannelHandshakeConfig { announce_for_forwarding: Readable::read(reader)?, commit_upfront_shutdown_pubkey: Readable::read(reader)?, their_channel_reserve_proportional_millionths: Readable::read(reader)?, + min_their_channel_reserve_satoshis: Readable::read(reader)?, negotiate_anchors_zero_fee_htlc_tx: Readable::read(reader)?, negotiate_anchor_zero_fee_commitments: Readable::read(reader)?, our_max_accepted_htlcs: Readable::read(reader)?, From c5a2ea22c50e3edc14f4285ddae2fb623577cf6a Mon Sep 17 00:00:00 2001 From: Fernando Ledesma Date: Mon, 2 Feb 2026 09:13:26 -0500 Subject: [PATCH 106/135] add payment hash to send webhook event --- lightning-liquidity/src/lsps4/event.rs | 4 ++++ lightning-liquidity/src/lsps4/service.rs | 1 + 2 files changed, 5 insertions(+) diff --git a/lightning-liquidity/src/lsps4/event.rs b/lightning-liquidity/src/lsps4/event.rs index bb96f43ab3c..a10dad682a0 100644 --- a/lightning-liquidity/src/lsps4/event.rs +++ b/lightning-liquidity/src/lsps4/event.rs @@ -12,6 +12,8 @@ use crate::lsps0::ser::LSPSRequestId; use std::string::String; use std::vec::Vec; +use lightning_types::payment::PaymentHash; + use bitcoin::secp256k1::PublicKey; @@ -47,6 +49,8 @@ pub enum LSPS4ServiceEvent { SendWebhook { /// The node id of the counterparty. counterparty_node_id: PublicKey, + /// The payment hash + payment_hash: PaymentHash, }, /// You should open a channel using [`ChannelManager::create_channel`]. /// diff --git a/lightning-liquidity/src/lsps4/service.rs b/lightning-liquidity/src/lsps4/service.rs index 20371027c1c..88a8fcf9c13 100644 --- a/lightning-liquidity/src/lsps4/service.rs +++ b/lightning-liquidity/src/lsps4/service.rs @@ -180,6 +180,7 @@ where let mut event_queue_notifier = self.pending_events.notifier(); event_queue_notifier.enqueue(crate::events::LiquidityEvent::LSPS4Service(LSPS4ServiceEvent::SendWebhook { counterparty_node_id: counterparty_node_id.clone(), + payment_hash, })); } else { log_debug!( From 4b9754b937d06969243c0f088a449c52d9193c74 Mon Sep 17 00:00:00 2001 From: Fernando Ledesma Date: Tue, 24 Feb 2026 10:47:12 -0500 Subject: [PATCH 107/135] Fix HTLC store race condition between insert and peer_connected When an HTLC is intercepted while the peer is offline, the store previously persisted to S3 before inserting into the in-memory HashMap. This created a window where peer_connected() could run, read an empty HashMap, and find 0 HTLCs to forward -- even though the HTLC was being stored at that exact moment. Two fixes: - htlc_store: Insert into HashMap before S3 persist (with rollback on failure) so the HTLC is immediately visible to other threads. - service: After storing, re-check if the peer reconnected during the persist window. If so, process HTLCs immediately rather than relying on the webhook path that already missed them. --- lightning-liquidity/src/lsps4/htlc_store.rs | 18 +++++++++------- lightning-liquidity/src/lsps4/service.rs | 23 ++++++++++++++++----- 2 files changed, 28 insertions(+), 13 deletions(-) diff --git a/lightning-liquidity/src/lsps4/htlc_store.rs b/lightning-liquidity/src/lsps4/htlc_store.rs index bef1584a91e..2f7963832b0 100644 --- a/lightning-liquidity/src/lsps4/htlc_store.rs +++ b/lightning-liquidity/src/lsps4/htlc_store.rs @@ -139,21 +139,23 @@ where L::Target: Logger, KV::Target: KVStoreSync { } pub(crate) fn insert(&self, htlc: InterceptedHtlc) -> Result { - // Check if already exists + // Insert into the in-memory map first for immediate visibility to other threads + // (e.g., peer_connected checking for pending HTLCs). Then persist to S3 for durability. { - let locked_htlcs = self.htlcs.lock().unwrap(); + let mut locked_htlcs = self.htlcs.lock().unwrap(); if locked_htlcs.contains_key(&htlc.id()) { return Ok(false); } + locked_htlcs.insert(htlc.id(), htlc.clone()); } - // Persist first (outside the lock) - self.persist(&htlc)?; + // Persist to durable storage. If this fails, remove from the map to stay consistent. + if let Err(e) = self.persist(&htlc) { + self.htlcs.lock().unwrap().remove(&htlc.id()); + return Err(e); + } - // Then insert into the map - let mut locked_htlcs = self.htlcs.lock().unwrap(); - let updated = locked_htlcs.insert(htlc.id(), htlc).is_some(); - Ok(updated) + Ok(true) } pub(crate) fn remove(&self, id: &InterceptId) -> Result<(), io::Error> { diff --git a/lightning-liquidity/src/lsps4/service.rs b/lightning-liquidity/src/lsps4/service.rs index 88a8fcf9c13..61e8dd1bc93 100644 --- a/lightning-liquidity/src/lsps4/service.rs +++ b/lightning-liquidity/src/lsps4/service.rs @@ -177,11 +177,24 @@ where counterparty_node_id ); self.htlc_store.insert(htlc).unwrap(); // TODO: handle persistence failures - let mut event_queue_notifier = self.pending_events.notifier(); - event_queue_notifier.enqueue(crate::events::LiquidityEvent::LSPS4Service(LSPS4ServiceEvent::SendWebhook { - counterparty_node_id: counterparty_node_id.clone(), - payment_hash, - })); + // Re-check: the peer may have reconnected while we were persisting. + // If so, peer_connected() already ran and found 0 HTLCs (race). + // Process the stored HTLC now instead of relying on the webhook. + if self.is_peer_connected(&counterparty_node_id) { + log_info!( + self.logger, + "[htlc_intercepted] Peer {} reconnected during HTLC store, processing immediately", + counterparty_node_id + ); + let htlcs = self.htlc_store.get_htlcs_by_node_id(&counterparty_node_id); + self.process_htlcs_for_peer(counterparty_node_id.clone(), htlcs); + } else { + let mut event_queue_notifier = self.pending_events.notifier(); + event_queue_notifier.enqueue(crate::events::LiquidityEvent::LSPS4Service(LSPS4ServiceEvent::SendWebhook { + counterparty_node_id: counterparty_node_id.clone(), + payment_hash, + })); + } } else { log_debug!( self.logger, From 1597781e3e26ce08b3e804b4f53a73f120e256d2 Mon Sep 17 00:00:00 2001 From: Martin Saposnic Date: Mon, 2 Mar 2026 14:36:05 -0300 Subject: [PATCH 108/135] Add diagnostic logging to prove peer_connected race condition Production logs show 16 instances over 7 days where peer_connected() fires and immediately tries to forward stored HTLCs, but the HTLC silently fails because the channel hasn't completed ChannelReestablish yet. The root cause: forward_intercepted_htlc() checks is_usable() (channel confirmed, not shutting down) which passes. But later, send_htlc() checks is_peer_disconnected() which fails because clear_peer_disconnected only happens after channel_reestablish completes - which is AFTER peer_connected fires. Timeline of the bug: 1. peer_connected fires (TCP/noise handshake complete) 2. LSPS4 calls forward_intercepted_htlc() on channel manager 3. forward_intercepted_htlc checks is_usable() - passes, returns Ok(()) 4. HTLC is queued in forward_htlcs map 5. process_pending_htlc_forwards -> send_htlc() checks is_peer_disconnected() - FAILS 6. HTLC is failed back to payer with no LSPS4-level error logged This commit adds logging at every step of this pipeline to confirm the theory and quantify the problem: LSPS4 service (lightning-liquidity): - peer_connected: log is_usable/is_channel_ready for all channels - peer_disconnected: log orphaned HTLCs left in store - channel_ready: log channel state and pending HTLCs - htlc_intercepted: log connected_peers set size and channel usability - execute_htlc_actions: log PRE-FORWARD channel state, forward result - calculate_htlc_actions: log is_usable per channel - handle_expired_htlcs: log details of expired HTLCs LDK core (lightning): - forward_intercepted_htlc: log is_usable vs is_live vs is_connected, emit WARNING when is_usable && !is_live (the race condition) - channel_reestablish: log when clear_peer_disconnected() fires - send_htlc: log when HTLC is rejected due to peer_disconnected - process_forward_htlcs: log channel liveness before queue_add_htlc Grafana queries to confirm the bug after deploying: - "forward_intercepted_htlc.*is_usable: true.*is_live: false" - "send_htlc: REJECTING HTLC.*peer_disconnected" - "peer_connected.*is_usable: false" --- lightning-liquidity/src/lsps4/service.rs | 291 ++++++++++++++++++++--- lightning/src/ln/channel.rs | 5 + lightning/src/ln/channelmanager.rs | 21 +- 3 files changed, 282 insertions(+), 35 deletions(-) diff --git a/lightning-liquidity/src/lsps4/service.rs b/lightning-liquidity/src/lsps4/service.rs index 61e8dd1bc93..5df00c56f76 100644 --- a/lightning-liquidity/src/lsps4/service.rs +++ b/lightning-liquidity/src/lsps4/service.rs @@ -46,14 +46,11 @@ use crate::lsps4::msgs::{ }; use std::string::String; +use std::time::Instant; use std::vec::Vec; const HTLC_EXPIRY_THRESHOLD_SECS: u64 = 10; -use crate::utils::async_poll::dummy_waker; -use core::task; -use std::future::Future; - /// Action to forward a specific HTLC through a channel #[derive(Debug)] pub(crate) struct HtlcForwardAction { @@ -170,11 +167,16 @@ where counterparty_node_id.clone(), ); - if !self.is_peer_connected(&counterparty_node_id) { - log_debug!( + let is_connected = self.is_peer_connected(&counterparty_node_id); + let connected_count = self.connected_peers.read().unwrap().len(); + + if !is_connected { + log_info!( self.logger, - "[htlc_intercepted] Peer {} not connected, storing HTLC and sending webhook", - counterparty_node_id + "[LSPS4] htlc_intercepted: peer {} NOT in connected_peers (set size: {}), storing HTLC and sending webhook. payment_hash: {}", + counterparty_node_id, + connected_count, + payment_hash ); self.htlc_store.insert(htlc).unwrap(); // TODO: handle persistence failures // Re-check: the peer may have reconnected while we were persisting. @@ -183,7 +185,7 @@ where if self.is_peer_connected(&counterparty_node_id) { log_info!( self.logger, - "[htlc_intercepted] Peer {} reconnected during HTLC store, processing immediately", + "[LSPS4] htlc_intercepted: peer {} reconnected during HTLC store, processing immediately", counterparty_node_id ); let htlcs = self.htlc_store.get_htlcs_by_node_id(&counterparty_node_id); @@ -196,11 +198,32 @@ where })); } } else { - log_debug!( + // Log channel states when taking the "connected" path + let channels = self + .channel_manager + .get_cm() + .list_channels_with_counterparty(&counterparty_node_id); + let any_usable = channels.iter().any(|ch| ch.is_usable); + log_info!( self.logger, - "[htlc_intercepted] Peer {} connected, processing HTLC immediately", - counterparty_node_id + "[LSPS4] htlc_intercepted: peer {} IS in connected_peers (set size: {}), processing immediately. channels: {}, any_usable: {}, payment_hash: {}", + counterparty_node_id, + connected_count, + channels.len(), + any_usable, + payment_hash ); + for ch in &channels { + log_info!( + self.logger, + "[LSPS4] htlc_intercepted: channel {} - is_usable: {}, is_channel_ready: {}, outbound_capacity: {}msat", + ch.channel_id, + ch.is_usable, + ch.is_channel_ready, + ch.outbound_capacity_msat + ); + } + let actions = self.calculate_htlc_actions_for_peer(counterparty_node_id, vec![htlc.clone()]); @@ -210,7 +233,7 @@ where log_debug!( self.logger, - "[htlc_intercepted] Calculated actions for peer {}: {:?}", + "[LSPS4] htlc_intercepted: calculated actions for peer {}: {:?}", counterparty_node_id, actions ); @@ -242,9 +265,40 @@ where pub fn channel_ready( &self, counterparty_node_id: &PublicKey, ) -> Result<(), APIError> { + let is_connected = self.is_peer_connected(counterparty_node_id); - if self.is_peer_connected(counterparty_node_id) { + log_info!( + self.logger, + "[LSPS4] channel_ready: peer {} (is_peer_connected: {})", + counterparty_node_id, + is_connected + ); + + if is_connected { let htlcs = self.htlc_store.get_htlcs_by_node_id(counterparty_node_id); + log_info!( + self.logger, + "[LSPS4] channel_ready: peer {} has {} HTLCs in store to process", + counterparty_node_id, + htlcs.len() + ); + + // Log channel states at channel_ready time - key diagnostic for race condition + let channels = self + .channel_manager + .get_cm() + .list_channels_with_counterparty(counterparty_node_id); + for ch in &channels { + log_info!( + self.logger, + "[LSPS4] channel_ready: channel {} - is_usable: {}, is_channel_ready: {}, outbound_capacity: {}msat", + ch.channel_id, + ch.is_usable, + ch.is_channel_ready, + ch.outbound_capacity_msat + ); + } + self.process_htlcs_for_peer(counterparty_node_id.clone(), htlcs); } @@ -253,24 +307,77 @@ where /// Will attempt to forward any pending intercepted htlcs to this counterparty. pub fn peer_connected(&self, counterparty_node_id: PublicKey) { + let fn_start = Instant::now(); + let connected_count = { + let mut peers = self.connected_peers.write().unwrap(); + peers.insert(counterparty_node_id); + peers.len() + }; + log_info!( self.logger, - "Peer connected: {} inserting into connected peers map", - counterparty_node_id + "[LSPS4] peer_connected: {} (total connected peers: {})", + counterparty_node_id, + connected_count ); - self.connected_peers.write().unwrap().insert(counterparty_node_id); + // Log channel states BEFORE attempting to forward - key diagnostic for race condition. + // If is_usable is false but is_channel_ready is true, the channel hasn't completed + // ChannelReestablish yet and forward_intercepted_htlc will silently fail later. + let channels = self + .channel_manager + .get_cm() + .list_channels_with_counterparty(&counterparty_node_id); + for ch in &channels { + log_info!( + self.logger, + "[LSPS4] peer_connected: channel {} with {} - is_usable: {}, is_channel_ready: {}, outbound_capacity: {}msat", + ch.channel_id, + counterparty_node_id, + ch.is_usable, + ch.is_channel_ready, + ch.outbound_capacity_msat + ); + } + if channels.is_empty() { + log_info!( + self.logger, + "[LSPS4] peer_connected: {} has NO channels", + counterparty_node_id + ); + } let htlcs = self.htlc_store.get_htlcs_by_node_id(&counterparty_node_id); + let htlc_count = htlcs.len(); log_info!( self.logger, - "{} has {} htlcs waiting to be forwarded", + "[LSPS4] peer_connected: {} has {} HTLCs waiting to be forwarded", counterparty_node_id, - htlcs.len() + htlc_count ); + if htlc_count > 0 { + for htlc in &htlcs { + log_info!( + self.logger, + "[LSPS4] peer_connected: pending HTLC {:?} - payment_hash: {}, amount: {}msat, scid: {}", + htlc.id(), + htlc.payment_hash(), + htlc.expected_outbound_amount_msat(), + htlc.requested_next_hop_scid() + ); + } + } + self.process_htlcs_for_peer(counterparty_node_id.clone(), htlcs); + + log_info!( + self.logger, + "[LSPS4] peer_connected: TOTAL took {}ms for {}", + fn_start.elapsed().as_millis(), + counterparty_node_id + ); } /// Handle expired HTLCs. @@ -278,11 +385,32 @@ where /// Will fail the HTLCs and remove them from the store. /// Needs to be called regularly to cleanup old htlcs. pub async fn handle_expired_htlcs(&self, now: u64) { - for htlc in self.htlc_store.get_expired_htlcs(now, HTLC_EXPIRY_THRESHOLD_SECS) { + let expired = self.htlc_store.get_expired_htlcs(now, HTLC_EXPIRY_THRESHOLD_SECS); + if !expired.is_empty() { + log_info!( + self.logger, + "[LSPS4] handle_expired_htlcs: found {} expired HTLCs (threshold: {}s, now: {})", + expired.len(), + HTLC_EXPIRY_THRESHOLD_SECS, + now + ); + } + + for htlc in expired { + log_info!( + self.logger, + "[LSPS4] handle_expired_htlcs: failing HTLC {:?} - payment_hash: {}, amount: {}msat, peer: {}", + htlc.id(), + htlc.payment_hash(), + htlc.expected_outbound_amount_msat(), + htlc.next_node_id() + ); + if let Err(e) = self.channel_manager.get_cm().fail_intercepted_htlc(htlc.id()) { log_error!( self.logger, - "HTLC was already failed when we tried to fail it: {:?}", + "[LSPS4] handle_expired_htlcs: HTLC {:?} was already failed: {:?}", + htlc.id(), e ); } @@ -290,7 +418,8 @@ where if let Err(e) = self.htlc_store.remove(&htlc.id()) { log_error!( self.logger, - "Failed to remove expired intercepted HTLC from store: {}", + "[LSPS4] handle_expired_htlcs: failed to remove HTLC {:?} from store: {}", + htlc.id(), e ); } @@ -303,7 +432,34 @@ where /// Will update the set of connected peers pub fn peer_disconnected(&self, counterparty_node_id: &PublicKey) { - self.connected_peers.write().unwrap().remove(counterparty_node_id); + let (was_present, remaining_count) = { + let mut peers = self.connected_peers.write().unwrap(); + let was = peers.remove(counterparty_node_id); + (was, peers.len()) + }; + + let pending_htlcs = self.htlc_store.get_htlcs_by_node_id(counterparty_node_id); + + log_info!( + self.logger, + "[LSPS4] peer_disconnected: {} (was_in_set: {}, remaining connected: {}, pending_htlcs_in_store: {})", + counterparty_node_id, + was_present, + remaining_count, + pending_htlcs.len() + ); + + if !pending_htlcs.is_empty() { + for htlc in &pending_htlcs { + log_info!( + self.logger, + "[LSPS4] peer_disconnected: ORPHANED HTLC {:?} left in store - payment_hash: {}, amount: {}msat", + htlc.id(), + htlc.payment_hash(), + htlc.expected_outbound_amount_msat() + ); + } + } } fn handle_register_node_request( @@ -393,9 +549,23 @@ where let mut channel_capacity_map: HashMap = new_hash_map(); for channel in &channels { channel_capacity_map.insert(channel.channel_id, channel.outbound_capacity_msat); + log_info!( + self.logger, + "[LSPS4] calculate_htlc_actions: channel {} - is_usable: {}, is_channel_ready: {}, outbound_capacity: {}msat", + channel.channel_id, + channel.is_usable, + channel.is_channel_ready, + channel.outbound_capacity_msat + ); } - log_info!(self.logger, "{} has {} channels with these outbound capacities: {:?}", their_node_id, channels.len(), channel_capacity_map); + log_info!( + self.logger, + "[LSPS4] calculate_htlc_actions: {} has {} channels, {} HTLCs to process", + their_node_id, + channels.len(), + htlcs.len() + ); struct ComputedHtlc { htlc: InterceptedHtlc, @@ -490,27 +660,84 @@ where ) { // Execute forwards for forward_action in actions.forwards { - log_debug!( + // Log channel state RIGHT BEFORE forwarding - key diagnostic + let channels = self + .channel_manager + .get_cm() + .list_channels_with_counterparty(&their_node_id); + let target_channel = + channels.iter().find(|ch| ch.channel_id == forward_action.channel_id); + if let Some(ch) = target_channel { + log_info!( + self.logger, + "[LSPS4] execute_htlc_actions: PRE-FORWARD channel {} - is_usable: {}, is_channel_ready: {}, outbound_capacity: {}msat", + ch.channel_id, + ch.is_usable, + ch.is_channel_ready, + ch.outbound_capacity_msat + ); + } else { + log_error!( + self.logger, + "[LSPS4] execute_htlc_actions: TARGET CHANNEL {} NOT FOUND for peer {} before forward!", + forward_action.channel_id, + their_node_id + ); + } + + log_info!( self.logger, - "Executing forward for HTLC {:?} through channel {} with amount {}msat (skimmed {}msat)", + "[LSPS4] execute_htlc_actions: forwarding HTLC {:?} through channel {} to peer {}, amount: {}msat, payment_hash: {}", forward_action.htlc.id(), forward_action.channel_id, + their_node_id, forward_action.amount_to_forward_msat, - forward_action.skimmed_fee_msat + forward_action.htlc.payment_hash() ); - if let Err(e) = self.channel_manager.get_cm().forward_intercepted_htlc( + let fwd_start = Instant::now(); + match self.channel_manager.get_cm().forward_intercepted_htlc( forward_action.htlc.id(), &forward_action.channel_id, forward_action.htlc.next_node_id(), forward_action.amount_to_forward_msat, ) { - log_error!(self.logger, "Failed to forward intercepted HTLC: {:?}", e); + Ok(()) => { + log_info!( + self.logger, + "[LSPS4] execute_htlc_actions: forward_intercepted_htlc returned Ok for HTLC {:?} (took {}ms). NOTE: Ok does NOT guarantee delivery - channel may still be reestablishing.", + forward_action.htlc.id(), + fwd_start.elapsed().as_millis() + ); + }, + Err(ref e) => { + log_error!( + self.logger, + "[LSPS4] execute_htlc_actions: forward_intercepted_htlc FAILED for HTLC {:?} - error: {:?} (took {}ms)", + forward_action.htlc.id(), + e, + fwd_start.elapsed().as_millis() + ); + }, } - // Remove the HTLC from store after forwarding - if let Err(e) = self.htlc_store.remove(&forward_action.htlc.id()) { - log_error!(self.logger, "Failed to remove intercepted HTLC from store: {}", e); + // Remove from store - log whether it was actually present + match self.htlc_store.remove(&forward_action.htlc.id()) { + Ok(()) => { + log_info!( + self.logger, + "[LSPS4] execute_htlc_actions: removed HTLC {:?} from store after forward", + forward_action.htlc.id() + ); + }, + Err(e) => { + log_info!( + self.logger, + "[LSPS4] execute_htlc_actions: HTLC {:?} was NOT in store (expected if from htlc_intercepted connected path): {}", + forward_action.htlc.id(), + e + ); + }, } } diff --git a/lightning/src/ln/channel.rs b/lightning/src/ln/channel.rs index 08b0249d8e7..84be091f831 100644 --- a/lightning/src/ln/channel.rs +++ b/lightning/src/ln/channel.rs @@ -9912,6 +9912,7 @@ where // Go ahead and unmark PeerDisconnected as various calls we may make check for it (and all // remaining cases either succeed or ErrorMessage-fail). + log_info!(logger, "channel_reestablish: clearing peer_disconnected flag for channel {} - channel will now be is_live()", self.context.channel_id()); self.context.channel_state.clear_peer_disconnected(); self.mark_response_received(); @@ -12685,6 +12686,10 @@ where // disconnected during the time the previous hop was doing the commitment dance we may // end up getting here after the forwarding delay. In any case, returning an // IgnoreError will get ChannelManager to do the right thing and fail backwards now. + log_info!(logger, + "send_htlc: REJECTING HTLC on channel {} - peer_disconnected flag is set. payment_hash: {}, amount: {}msat. Channel was is_usable() at forward_intercepted_htlc time but is NOT is_live() now.", + self.context.channel_id(), payment_hash, amount_msat + ); return Err(( LocalHTLCFailureReason::PeerOffline, "Cannot send an HTLC while disconnected from channel counterparty".to_owned(), diff --git a/lightning/src/ln/channelmanager.rs b/lightning/src/ln/channelmanager.rs index 8b08fe0c9df..d1dc0a866f0 100644 --- a/lightning/src/ln/channelmanager.rs +++ b/lightning/src/ln/channelmanager.rs @@ -6764,13 +6764,26 @@ where match peer_state.channel_by_id.get(next_hop_channel_id) { Some(chan) => { if let Some(funded_chan) = chan.as_funded() { - if !funded_chan.context.is_usable() { + let is_usable = funded_chan.context.is_usable(); + let is_live = funded_chan.context.is_live(); + let is_connected = funded_chan.context.is_connected(); + log_info!(entry_logger, + "forward_intercepted_htlc: channel {} - is_usable: {}, is_live: {}, is_connected: {}, peer_state.is_connected: {}", + next_hop_channel_id, is_usable, is_live, is_connected, peer_state.is_connected + ); + if !is_usable { return Err(APIError::ChannelUnavailable { err: format!( "Channel with id {next_hop_channel_id} not fully established" ), }); } + if !is_live { + log_info!(entry_logger, + "forward_intercepted_htlc: WARNING - channel {} is_usable but NOT is_live (peer_disconnected flag set). HTLC will be queued but will fail at send_htlc time.", + next_hop_channel_id + ); + } funded_chan.context.outbound_scid_alias() } else { return Err(APIError::ChannelUnavailable { @@ -7534,8 +7547,10 @@ where } else { "alternate" }; - log_trace!(logger, "Forwarding HTLC from SCID {} with payment_hash {} and next hop SCID {} over {} channel {} with corresponding peer {}", - prev_outbound_scid_alias, &payment_hash, short_chan_id, channel_description, optimal_channel.context.channel_id(), &counterparty_node_id); + log_info!(logger, "process_forward_htlcs: forwarding HTLC from SCID {} with payment_hash {} and next hop SCID {} over {} channel {} with peer {} - is_live: {}, is_usable: {}, is_connected: {}", + prev_outbound_scid_alias, &payment_hash, short_chan_id, channel_description, + optimal_channel.context.channel_id(), &counterparty_node_id, + optimal_channel.context.is_live(), optimal_channel.context.is_usable(), optimal_channel.context.is_connected()); if let Err((reason, msg)) = optimal_channel.queue_add_htlc( *outgoing_amt_msat, *payment_hash, From 37128ac8c84b132cffb05c4c0eb19c06c3ea4fd0 Mon Sep 17 00:00:00 2001 From: Martin Saposnic Date: Mon, 2 Mar 2026 23:18:20 -0300 Subject: [PATCH 109/135] chore: bump HTLC_EXPIRY_THRESHOLD_SECS from 10 to 45 The 10s threshold was too tight for the webhook delivery pipeline, causing premature HTLC expiry and RetriesExhausted failures on 402 payments. 45s gives ample room for webhook round-trips while still well within CLTV delta bounds. --- lightning-liquidity/src/lsps4/service.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lightning-liquidity/src/lsps4/service.rs b/lightning-liquidity/src/lsps4/service.rs index 5df00c56f76..e2ceaaf6534 100644 --- a/lightning-liquidity/src/lsps4/service.rs +++ b/lightning-liquidity/src/lsps4/service.rs @@ -49,7 +49,7 @@ use std::string::String; use std::time::Instant; use std::vec::Vec; -const HTLC_EXPIRY_THRESHOLD_SECS: u64 = 10; +const HTLC_EXPIRY_THRESHOLD_SECS: u64 = 45; /// Action to forward a specific HTLC through a channel #[derive(Debug)] From 65951e212aa4877a061a7d807434d23a1902eff7 Mon Sep 17 00:00:00 2001 From: Austin Mackillop Date: Wed, 11 Mar 2026 20:56:39 +0000 Subject: [PATCH 110/135] Defer LSPS4 HTLC forwarding until channel usable (#6) LDK fires peer_connected after the TCP+Init handshake but before channel_reestablish completes. During this window, channels exist but are not yet usable (is_usable=false). The previous code forwarded HTLCs unconditionally in peer_connected and htlc_intercepted, causing ~10% of payments to fail on reconnect. Distinguish two connected-peer cases in htlc_intercepted and peer_connected: - Channels exist but none usable (reestablish in progress): defer the HTLC for timer-based retry via process_pending_htlcs(). - No channels exist (first payment, JIT open needed): proceed immediately so calculate_htlc_actions_for_peer can emit the OpenChannel event. process_pending_htlcs (5s timer) only retries the reestablish case (channels exist, waiting to become usable). It must not handle the no-channel case to avoid emitting duplicate OpenChannel events while a JIT open is already in flight. Remove the re-check race in htlc_intercepted that could fire concurrently with peer_connected. The webhook + peer_connected path is the single owner of the offline-peer reconnect flow. Blocking inside peer_connected was also considered but rejected: there is no LDK event for "channel usable after reconnect" to wake on, so it would require a spin-wait with arbitrary timeout. A timer-based retry is cleaner and avoids holding the lock. --- lightning-liquidity/src/lsps4/service.rs | 134 +++++++++++++++++------ 1 file changed, 100 insertions(+), 34 deletions(-) diff --git a/lightning-liquidity/src/lsps4/service.rs b/lightning-liquidity/src/lsps4/service.rs index e2ceaaf6534..1a5394168a7 100644 --- a/lightning-liquidity/src/lsps4/service.rs +++ b/lightning-liquidity/src/lsps4/service.rs @@ -179,34 +179,25 @@ where payment_hash ); self.htlc_store.insert(htlc).unwrap(); // TODO: handle persistence failures - // Re-check: the peer may have reconnected while we were persisting. - // If so, peer_connected() already ran and found 0 HTLCs (race). - // Process the stored HTLC now instead of relying on the webhook. - if self.is_peer_connected(&counterparty_node_id) { - log_info!( - self.logger, - "[LSPS4] htlc_intercepted: peer {} reconnected during HTLC store, processing immediately", - counterparty_node_id - ); - let htlcs = self.htlc_store.get_htlcs_by_node_id(&counterparty_node_id); - self.process_htlcs_for_peer(counterparty_node_id.clone(), htlcs); - } else { - let mut event_queue_notifier = self.pending_events.notifier(); - event_queue_notifier.enqueue(crate::events::LiquidityEvent::LSPS4Service(LSPS4ServiceEvent::SendWebhook { - counterparty_node_id: counterparty_node_id.clone(), - payment_hash, - })); - } + // Send webhook to wake the peer. When the peer reconnects, + // peer_connected() will find this HTLC in the store and process it. + // The process_pending_htlcs timer also serves as a fallback. + let mut event_queue_notifier = self.pending_events.notifier(); + event_queue_notifier.enqueue(crate::events::LiquidityEvent::LSPS4Service(LSPS4ServiceEvent::SendWebhook { + counterparty_node_id: counterparty_node_id.clone(), + payment_hash, + })); } else { - // Log channel states when taking the "connected" path let channels = self .channel_manager .get_cm() .list_channels_with_counterparty(&counterparty_node_id); let any_usable = channels.iter().any(|ch| ch.is_usable); + let has_channels = !channels.is_empty(); + log_info!( self.logger, - "[LSPS4] htlc_intercepted: peer {} IS in connected_peers (set size: {}), processing immediately. channels: {}, any_usable: {}, payment_hash: {}", + "[LSPS4] htlc_intercepted: peer {} IS in connected_peers (set size: {}), channels: {}, any_usable: {}, payment_hash: {}", counterparty_node_id, connected_count, channels.len(), @@ -224,21 +215,40 @@ where ); } - let actions = - self.calculate_htlc_actions_for_peer(counterparty_node_id, vec![htlc.clone()]); + if has_channels && !any_usable { + // Channels exist but none usable yet (channel_reestablish in progress). + // Defer until process_pending_htlcs picks them up. + log_info!( + self.logger, + "[LSPS4] htlc_intercepted: peer {} has {} channels but none usable, \ + deferring HTLC until channel_reestablish completes. payment_hash: {}", + counterparty_node_id, + channels.len(), + payment_hash + ); + self.htlc_store.insert(htlc).unwrap(); + } else { + // Either channels are usable, or no channels exist (need JIT open). + // calculate_htlc_actions_for_peer handles both: forward through usable + // channels, or emit OpenChannel event when no capacity exists. + let actions = self.calculate_htlc_actions_for_peer( + counterparty_node_id, + vec![htlc.clone()], + ); - if actions.new_channel_needed_msat.is_some() { - self.htlc_store.insert(htlc).unwrap(); // TODO: handle persistence failures - } + if actions.new_channel_needed_msat.is_some() { + self.htlc_store.insert(htlc).unwrap(); + } - log_debug!( - self.logger, - "[LSPS4] htlc_intercepted: calculated actions for peer {}: {:?}", - counterparty_node_id, - actions - ); + log_debug!( + self.logger, + "[LSPS4] htlc_intercepted: calculated actions for peer {}: {:?}", + counterparty_node_id, + actions + ); - self.execute_htlc_actions(actions, counterparty_node_id.clone()); + self.execute_htlc_actions(actions, counterparty_node_id.clone()); + } } } else { log_error!( @@ -305,7 +315,12 @@ where Ok(()) } - /// Will attempt to forward any pending intercepted htlcs to this counterparty. + /// Will attempt to forward any pending intercepted htlcs to this counterparty, + /// but only if a usable channel exists. After reconnect, channels are not usable + /// until channel_reestablish completes. Call [`process_pending_htlcs`] on a timer + /// to retry peers whose channels were not yet usable at connect time. + /// + /// [`process_pending_htlcs`]: Self::process_pending_htlcs pub fn peer_connected(&self, counterparty_node_id: PublicKey) { let fn_start = Instant::now(); let connected_count = { @@ -370,7 +385,20 @@ where } } - self.process_htlcs_for_peer(counterparty_node_id.clone(), htlcs); + if self.has_usable_channel(&counterparty_node_id) || channels.is_empty() { + // Either channels are usable (forward immediately) or no channels exist + // at all (process_htlcs_for_peer will trigger OpenChannel for JIT). + self.process_htlcs_for_peer(counterparty_node_id.clone(), htlcs); + } else { + // Channels exist but none usable: reestablish still in progress. + // Defer until process_pending_htlcs picks them up on the timer. + log_info!( + self.logger, + "[LSPS4] peer_connected: {counterparty_node_id} has {} pending HTLCs but channels \ + not yet usable (reestablish in progress), deferring", + htlcs.len() + ); + } log_info!( self.logger, @@ -378,6 +406,8 @@ where fn_start.elapsed().as_millis(), counterparty_node_id ); + + } /// Handle expired HTLCs. @@ -430,6 +460,14 @@ where self.connected_peers.read().unwrap().contains(counterparty_node_id) } + fn has_usable_channel(&self, counterparty_node_id: &PublicKey) -> bool { + self.channel_manager + .get_cm() + .list_channels_with_counterparty(counterparty_node_id) + .iter() + .any(|c| c.is_usable) + } + /// Will update the set of connected peers pub fn peer_disconnected(&self, counterparty_node_id: &PublicKey) { let (was_present, remaining_count) = { @@ -462,6 +500,34 @@ where } } + /// Attempt to forward pending HTLCs for all connected peers that have usable channels. + /// + /// After reconnect, `peer_connected` fires before `channel_reestablish` completes, + /// so channels are not yet usable. This method should be called on a regular timer + /// (e.g. every few seconds) to pick up HTLCs that were deferred at connect time. + pub fn process_pending_htlcs(&self) { + let connected_peers: Vec = + self.connected_peers.read().unwrap().iter().copied().collect(); + + for node_id in connected_peers { + let htlcs = self.htlc_store.get_htlcs_by_node_id(&node_id); + if htlcs.is_empty() { + continue; + } + + if self.has_usable_channel(&node_id) { + // Channel reestablish completed — forward the deferred HTLCs. + log_info!( + self.logger, + "[LSPS4] process_pending_htlcs: forwarding {} HTLCs for peer {} (channel now usable)", + htlcs.len(), + node_id + ); + self.process_htlcs_for_peer(node_id, htlcs); + } + } + } + fn handle_register_node_request( &self, request_id: LSPSRequestId, counterparty_node_id: &PublicKey, _params: RegisterNodeRequest, ) -> Result<(), LightningError> { From 2fd69004414bd84232d1bc6aa4bb311a7d1c339d Mon Sep 17 00:00:00 2001 From: Austin Mackillop Date: Fri, 13 Mar 2026 14:15:56 +0000 Subject: [PATCH 111/135] Fix TOCTOU races in LSPS4 JIT channel flow (#7) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit process_pending_htlcs and htlc_intercepted both call calculate_htlc_actions_for_peer independently. When both observe insufficient capacity on an existing channel, both emit OpenChannel — opening two JIT channels for one payment. The timer's role is to retry deferred forwards through channels that became usable after reestablish, not to open new channels. Restrict it to forwarding only: if the action set contains a new channel request, skip and let htlc_intercepted, peer_connected, or channel_ready handle it. A second race hid behind non-usable channels appearing in the capacity map. A still-opening channel advertised high outbound_capacity, so the HTLC was routed into it. The forward failed (channel still opening), consuming the InterceptId. channel_ready fired moments later but found an empty store — the payment hung until the payer retried. Filter the capacity map to usable channels only. A third window exists between the usability check and the actual forward_intercepted_htlc call: the peer can disconnect in between, wasting the InterceptId on a doomed forward. Add a peer liveness check immediately before forwarding so the HTLC survives for retry on the next timer tick. The residual sub-millisecond window is acceptable; plumbing connection state into the channel manager's forward path would be far more invasive for marginal gain. --- lightning-liquidity/src/lsps4/service.rs | 95 +++++++++++++----------- 1 file changed, 53 insertions(+), 42 deletions(-) diff --git a/lightning-liquidity/src/lsps4/service.rs b/lightning-liquidity/src/lsps4/service.rs index 1a5394168a7..5d9b7ee673e 100644 --- a/lightning-liquidity/src/lsps4/service.rs +++ b/lightning-liquidity/src/lsps4/service.rs @@ -523,7 +523,20 @@ where htlcs.len(), node_id ); - self.process_htlcs_for_peer(node_id, htlcs); + let actions = self.calculate_htlc_actions_for_peer(node_id, htlcs); + if actions.new_channel_needed_msat.is_some() { + // A channel open is already in flight from htlc_intercepted or + // peer_connected. Skip — channel_ready will handle forwarding + // once the new channel is established. + log_info!( + self.logger, + "[LSPS4] process_pending_htlcs: peer {} needs a new channel, \ + skipping (channel open already in flight)", + node_id + ); + continue; + } + self.execute_htlc_actions(actions, node_id); } } } @@ -614,6 +627,9 @@ where let mut channel_capacity_map: HashMap = new_hash_map(); for channel in &channels { + if !channel.is_usable { + continue; + } channel_capacity_map.insert(channel.channel_id, channel.outbound_capacity_msat); log_info!( self.logger, @@ -726,29 +742,17 @@ where ) { // Execute forwards for forward_action in actions.forwards { - // Log channel state RIGHT BEFORE forwarding - key diagnostic - let channels = self - .channel_manager - .get_cm() - .list_channels_with_counterparty(&their_node_id); - let target_channel = - channels.iter().find(|ch| ch.channel_id == forward_action.channel_id); - if let Some(ch) = target_channel { + // Re-check peer liveness right before forwarding to narrow the + // TOCTOU window between the usability check and the actual forward. + if !self.is_peer_connected(&their_node_id) { log_info!( self.logger, - "[LSPS4] execute_htlc_actions: PRE-FORWARD channel {} - is_usable: {}, is_channel_ready: {}, outbound_capacity: {}msat", - ch.channel_id, - ch.is_usable, - ch.is_channel_ready, - ch.outbound_capacity_msat - ); - } else { - log_error!( - self.logger, - "[LSPS4] execute_htlc_actions: TARGET CHANNEL {} NOT FOUND for peer {} before forward!", - forward_action.channel_id, - their_node_id + "[LSPS4] execute_htlc_actions: peer {} disconnected before forward, skipping HTLC {:?} (will retry on next timer tick). payment_hash: {}", + their_node_id, + forward_action.htlc.id(), + forward_action.htlc.payment_hash() ); + continue; } log_info!( @@ -771,39 +775,46 @@ where Ok(()) => { log_info!( self.logger, - "[LSPS4] execute_htlc_actions: forward_intercepted_htlc returned Ok for HTLC {:?} (took {}ms). NOTE: Ok does NOT guarantee delivery - channel may still be reestablishing.", + "[LSPS4] execute_htlc_actions: forward_intercepted_htlc returned Ok for HTLC {:?} \ + (took {}ms). payment_hash: {}. NOTE: Ok does NOT guarantee delivery - channel may still be reestablishing.", forward_action.htlc.id(), - fwd_start.elapsed().as_millis() + fwd_start.elapsed().as_millis(), + forward_action.htlc.payment_hash() ); }, Err(ref e) => { - log_error!( + log_info!( self.logger, - "[LSPS4] execute_htlc_actions: forward_intercepted_htlc FAILED for HTLC {:?} - error: {:?} (took {}ms)", + "[LSPS4] execute_htlc_actions: forward returned Err for HTLC {:?}: {:?} \ + (took {}ms). payment_hash: {}", forward_action.htlc.id(), e, - fwd_start.elapsed().as_millis() + fwd_start.elapsed().as_millis(), + forward_action.htlc.payment_hash() ); }, } - // Remove from store - log whether it was actually present + // Always remove from store after forward attempt. On Ok the InterceptId is + // consumed and the HTLC is in-flight — we cannot defer removal because the + // in-flight HTLC reduces outbound capacity, causing the next timer tick to + // see insufficient capacity and emit a spurious OpenChannel. If send_htlc + // later fails internally, LDK re-emits HTLCIntercepted with a fresh + // InterceptId. On Err the InterceptId was already consumed or stale. match self.htlc_store.remove(&forward_action.htlc.id()) { - Ok(()) => { - log_info!( - self.logger, - "[LSPS4] execute_htlc_actions: removed HTLC {:?} from store after forward", - forward_action.htlc.id() - ); - }, - Err(e) => { - log_info!( - self.logger, - "[LSPS4] execute_htlc_actions: HTLC {:?} was NOT in store (expected if from htlc_intercepted connected path): {}", - forward_action.htlc.id(), - e - ); - }, + Ok(()) => log_info!( + self.logger, + "[LSPS4] execute_htlc_actions: removed HTLC {:?} from store. payment_hash: {}", + forward_action.htlc.id(), + forward_action.htlc.payment_hash() + ), + Err(e) => log_info!( + self.logger, + "[LSPS4] execute_htlc_actions: HTLC {:?} not in store: {}. payment_hash: {}", + forward_action.htlc.id(), + e, + forward_action.htlc.payment_hash() + ), } } From 6d31cd77f5cf30630340e377464fa1202e748b41 Mon Sep 17 00:00:00 2001 From: Martin Saposnic Date: Fri, 20 Mar 2026 09:43:31 -0300 Subject: [PATCH 112/135] fix(lsps4): trigger channel open at peer_connected even during reestablish When an HTLC arrives for an offline peer, htlc_intercepted stores it and sends a webhook. When the peer reconnects, peer_connected deferred all processing if channels existed but weren't usable yet (reestablish in progress). Later, process_pending_htlcs found insufficient capacity but assumed a channel open was already in flight - nobody ever opened the channel. Fix: call process_htlcs_for_peer even when channels aren't usable. calculate_htlc_actions skips non-usable channels, so if existing capacity is insufficient it returns new_channel_needed_msat and execute_htlc_actions emits OpenChannel. No premature forwarding occurs since the forwards list is empty (no usable channels). The actual HTLC forwards happen via channel_ready once the new channel is established. --- lightning-liquidity/src/lsps4/service.rs | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/lightning-liquidity/src/lsps4/service.rs b/lightning-liquidity/src/lsps4/service.rs index 5d9b7ee673e..52b23f31170 100644 --- a/lightning-liquidity/src/lsps4/service.rs +++ b/lightning-liquidity/src/lsps4/service.rs @@ -390,14 +390,22 @@ where // at all (process_htlcs_for_peer will trigger OpenChannel for JIT). self.process_htlcs_for_peer(counterparty_node_id.clone(), htlcs); } else { - // Channels exist but none usable: reestablish still in progress. - // Defer until process_pending_htlcs picks them up on the timer. + // Channels exist but none usable yet (reestablish in progress). + // We still call process_htlcs_for_peer because calculate_htlc_actions + // skips non-usable channels. If existing capacity is insufficient, this + // will emit OpenChannel now rather than deferring to process_pending_htlcs + // (which would never open a channel). Forwards through existing channels + // will be empty since none are usable, so no premature forwarding occurs. + // The actual forwards happen later via channel_ready or process_pending_htlcs + // once reestablish completes. log_info!( self.logger, - "[LSPS4] peer_connected: {counterparty_node_id} has {} pending HTLCs but channels \ - not yet usable (reestablish in progress), deferring", + "[LSPS4] peer_connected: {} has {} pending HTLCs, channels not yet usable \ + (reestablish in progress) - checking if new channel needed", + counterparty_node_id, htlcs.len() ); + self.process_htlcs_for_peer(counterparty_node_id.clone(), htlcs); } log_info!( From 0e2dbb882b37846e4784f926af2b31fdea3fe991 Mon Sep 17 00:00:00 2001 From: Austin Mackillop Date: Wed, 1 Apr 2026 19:28:29 +0100 Subject: [PATCH 113/135] LSPS4 Splicing (#12) Instead of always opening new channels when outbound capacity is insufficient, prefer splicing into the largest existing usable channel. Falls back to new channel open when no usable channels exist. Key changes: - Add SpliceChannel event variant to LSPS4ServiceEvent - Add per-peer liquidity_cooldown to prevent 1Hz retry loops - Modify calculate_htlc_actions_for_peer to prefer splice over new channel - Add cooldown checks in execute_htlc_actions and process_pending_htlcs - Clear cooldown on channel_ready (covers both splice-lock and new channel) Use is_channel_ready instead of is_usable when selecting splice candidates. This ensures we prefer splice over new channel even during channel_reestablish (~1s window). splice_channel() will fail with "pending open/close" if the channel isn't usable yet, and the timer retries once reestablishment completes. --------- Co-authored-by: Martin Saposnic --- lightning-liquidity/src/lsps4/event.rs | 21 +- lightning-liquidity/src/lsps4/service.rs | 274 ++++++++++++++++++++--- 2 files changed, 258 insertions(+), 37 deletions(-) diff --git a/lightning-liquidity/src/lsps4/event.rs b/lightning-liquidity/src/lsps4/event.rs index a10dad682a0..b6f40feeb2e 100644 --- a/lightning-liquidity/src/lsps4/event.rs +++ b/lightning-liquidity/src/lsps4/event.rs @@ -12,10 +12,9 @@ use crate::lsps0::ser::LSPSRequestId; use std::string::String; use std::vec::Vec; +use lightning::ln::types::ChannelId; use lightning_types::payment::PaymentHash; - - use bitcoin::secp256k1::PublicKey; /// An event which an LSPS4 client should take some action in response to. @@ -63,4 +62,22 @@ pub enum LSPS4ServiceEvent { /// The number of channels currently open with the peer when the request is made. channel_count: usize, }, + /// You should splice into an existing channel to add capacity. + /// + /// If the splice fails, fall back to opening a new channel using the same sizing policy + /// as [`OpenChannel`]. + /// + /// [`OpenChannel`]: Self::OpenChannel + SpliceChannel { + /// The node whose channel to splice. + their_network_key: PublicKey, + /// The channel to splice into (the largest usable channel with this peer). + channel_id: ChannelId, + /// The user channel id, for diagnostics. + user_channel_id: u128, + /// Additional capacity needed (msat). The splice-in amount should be at least this. + amt_to_forward_msat: u64, + /// The number of channels currently open with the peer. + channel_count: usize, + }, } diff --git a/lightning-liquidity/src/lsps4/service.rs b/lightning-liquidity/src/lsps4/service.rs index 52b23f31170..8ab023d07c7 100644 --- a/lightning-liquidity/src/lsps4/service.rs +++ b/lightning-liquidity/src/lsps4/service.rs @@ -51,6 +51,11 @@ use std::vec::Vec; const HTLC_EXPIRY_THRESHOLD_SECS: u64 = 45; +/// Cooldown duration for liquidity actions. After a splice or channel open is emitted, +/// suppress further liquidity actions for this peer until the cooldown expires. +/// Set below HTLC_EXPIRY_THRESHOLD_SECS so at most 1 wasted retry occurs before expiry. +const LIQUIDITY_COOLDOWN_SECS: u64 = 30; + /// Action to forward a specific HTLC through a channel #[derive(Debug)] pub(crate) struct HtlcForwardAction { @@ -60,17 +65,32 @@ pub(crate) struct HtlcForwardAction { pub skimmed_fee_msat: u64, } +/// Action to splice into an existing channel to add capacity. +#[derive(Debug)] +pub(crate) struct SpliceAction { + pub channel_id: ChannelId, + pub user_channel_id: u128, + pub amt_to_forward_msat: u64, +} + /// Actions to take for processing HTLCs for a peer #[derive(Debug)] pub(crate) struct HtlcProcessingActions { pub forwards: Vec, pub new_channel_needed_msat: Option, + pub splice_needed: Option, pub channel_count: usize, } impl HtlcProcessingActions { pub fn is_empty(&self) -> bool { - self.forwards.is_empty() && self.new_channel_needed_msat.is_none() + self.forwards.is_empty() + && self.new_channel_needed_msat.is_none() + && self.splice_needed.is_none() + } + + pub fn needs_liquidity_action(&self) -> bool { + self.new_channel_needed_msat.is_some() || self.splice_needed.is_some() } pub fn total_forward_amount(&self) -> u64 { @@ -102,6 +122,10 @@ where htlc_store: HTLCStore, connected_peers: RwLock>, config: LSPS4ServiceConfig, + /// Per-peer timestamped cooldown for liquidity actions (splice or channel open). + /// Prevents 1Hz retry loops from process_pending_htlcs when actions fail. + /// Auto-expires after LIQUIDITY_COOLDOWN_SECS. + liquidity_cooldown: RwLock>, } impl LSPS4ServiceHandler @@ -128,6 +152,7 @@ where config, logger, connected_peers: RwLock::new(HashSet::new()), + liquidity_cooldown: RwLock::new(new_hash_map()), }) } @@ -236,7 +261,7 @@ where vec![htlc.clone()], ); - if actions.new_channel_needed_msat.is_some() { + if actions.needs_liquidity_action() { self.htlc_store.insert(htlc).unwrap(); } @@ -275,6 +300,10 @@ where pub fn channel_ready( &self, counterparty_node_id: &PublicKey, ) -> Result<(), APIError> { + // Clear liquidity cooldown on channel_ready. This fires for both new channels + // and splice completions (splice_locked), allowing immediate retry if needed. + self.liquidity_cooldown.write().unwrap().remove(counterparty_node_id); + let is_connected = self.is_peer_connected(counterparty_node_id); log_info!( @@ -476,6 +505,35 @@ where .any(|c| c.is_usable) } + fn is_liquidity_cooling_down(&self, peer: &PublicKey) -> bool { + self.liquidity_cooldown + .read() + .unwrap() + .get(peer) + .map(|t| t.elapsed().as_secs() < LIQUIDITY_COOLDOWN_SECS) + .unwrap_or(false) + } + + /// Called when a liquidity action (splice or channel open) completes successfully. + /// Clears the cooldown so new actions can be emitted immediately. + pub fn liquidity_action_completed(&self, peer: &PublicKey) { + self.liquidity_cooldown.write().unwrap().remove(peer); + } + + /// Called when a liquidity action fails asynchronously (e.g., ChannelClosed before ready). + /// Clears the cooldown so the timer or next HTLC can retry immediately. + pub fn liquidity_action_failed(&self, peer: &PublicKey) { + self.liquidity_cooldown.write().unwrap().remove(peer); + } + + /// Called when a splice fails asynchronously (SpliceFailed event). + /// Resets the cooldown timestamp instead of clearing it entirely. + /// This allows retry after LIQUIDITY_COOLDOWN_SECS, not immediately, + /// preventing 1Hz loops on persistent async splice failures. + pub fn liquidity_action_reset_cooldown(&self, peer: &PublicKey) { + self.liquidity_cooldown.write().unwrap().insert(*peer, Instant::now()); + } + /// Will update the set of connected peers pub fn peer_disconnected(&self, counterparty_node_id: &PublicKey) { let (was_present, remaining_count) = { @@ -518,30 +576,21 @@ where self.connected_peers.read().unwrap().iter().copied().collect(); for node_id in connected_peers { + // Skip peers with a liquidity action cooling down. + // Prevents 1Hz retry loops. execute_htlc_actions also checks this + // as a safety net for the htlc_intercepted path. + if self.is_liquidity_cooling_down(&node_id) { + continue; + } + let htlcs = self.htlc_store.get_htlcs_by_node_id(&node_id); if htlcs.is_empty() { continue; } if self.has_usable_channel(&node_id) { - // Channel reestablish completed — forward the deferred HTLCs. - log_info!( - self.logger, - "[LSPS4] process_pending_htlcs: forwarding {} HTLCs for peer {} (channel now usable)", - htlcs.len(), - node_id - ); let actions = self.calculate_htlc_actions_for_peer(node_id, htlcs); - if actions.new_channel_needed_msat.is_some() { - // A channel open is already in flight from htlc_intercepted or - // peer_connected. Skip — channel_ready will handle forwarding - // once the new channel is established. - log_info!( - self.logger, - "[LSPS4] process_pending_htlcs: peer {} needs a new channel, \ - skipping (channel open already in flight)", - node_id - ); + if actions.is_empty() { continue; } self.execute_htlc_actions(actions, node_id); @@ -727,29 +776,65 @@ where } if !can_forward { - // No existing channel has sufficient capacity, need to open a new channel + // No existing channel has sufficient capacity. // Calculate total amount needed for remaining HTLCs (including current one) let total_remaining_amount = computed_htlcs .iter() .fold(required_amount, |acc, h| acc.saturating_add(h.amount_to_forward_msat)); + // Prefer splicing into the largest usable channel over opening a new one. + // Use is_channel_ready (not is_usable) so we prefer splice even during + // channel_reestablish. splice_channel() will fail if the channel isn't + // usable yet, and the timer will retry once reestablishment completes. + let splice_candidate = channels + .iter() + .filter(|c| c.is_channel_ready) + .max_by_key(|c| c.channel_value_satoshis); + + if let Some(candidate) = splice_candidate { + return HtlcProcessingActions { + forwards, + new_channel_needed_msat: None, + splice_needed: Some(SpliceAction { + channel_id: candidate.channel_id, + user_channel_id: candidate.user_channel_id, + amt_to_forward_msat: total_remaining_amount, + }), + channel_count, + }; + } + + // No usable channels to splice into - need a new channel. return HtlcProcessingActions { forwards, new_channel_needed_msat: Some(total_remaining_amount), + splice_needed: None, channel_count, }; } } - HtlcProcessingActions { forwards, new_channel_needed_msat: None, channel_count } + HtlcProcessingActions { + forwards, + new_channel_needed_msat: None, + splice_needed: None, + channel_count, + } } /// Execute the actions calculated by calculate_htlc_actions_for_peer pub(crate) fn execute_htlc_actions( &self, actions: HtlcProcessingActions, their_node_id: PublicKey, ) { + let HtlcProcessingActions { + forwards, + new_channel_needed_msat, + splice_needed, + channel_count, + } = actions; + // Execute forwards - for forward_action in actions.forwards { + for forward_action in forwards { // Re-check peer liveness right before forwarding to narrow the // TOCTOU window between the usability check and the actual forward. if !self.is_peer_connected(&their_node_id) { @@ -826,21 +911,55 @@ where } } - // Handle new channel opening - if let Some(channel_size_msat) = actions.new_channel_needed_msat { - log_info!( - self.logger, - "Need a new channel with peer {} for {}msat to forward HTLCs", - their_node_id, - channel_size_msat - ); + // Handle liquidity actions (splice or new channel) + let needs_liquidity = new_channel_needed_msat.is_some() || splice_needed.is_some(); + if needs_liquidity { + if self.is_liquidity_cooling_down(&their_node_id) { + log_info!( + self.logger, + "[LSPS4] Liquidity action suppressed for peer {} (cooling down)", + their_node_id + ); + return; + } + + // Set cooldown BEFORE emitting to prevent re-entrant emit on next tick. + self.liquidity_cooldown.write().unwrap().insert(their_node_id, Instant::now()); let mut event_queue_notifier = self.pending_events.notifier(); - event_queue_notifier.enqueue(crate::events::LiquidityEvent::LSPS4Service(LSPS4ServiceEvent::OpenChannel { - their_network_key: their_node_id, - amt_to_forward_msat: channel_size_msat, - channel_count: actions.channel_count, - })); + + if let Some(splice_action) = splice_needed { + log_info!( + self.logger, + "[LSPS4] Splicing into channel {} with peer {} for {}msat", + splice_action.channel_id, + their_node_id, + splice_action.amt_to_forward_msat + ); + event_queue_notifier.enqueue(crate::events::LiquidityEvent::LSPS4Service( + LSPS4ServiceEvent::SpliceChannel { + their_network_key: their_node_id, + channel_id: splice_action.channel_id, + user_channel_id: splice_action.user_channel_id, + amt_to_forward_msat: splice_action.amt_to_forward_msat, + channel_count, + }, + )); + } else if let Some(channel_size_msat) = new_channel_needed_msat { + log_info!( + self.logger, + "[LSPS4] Need a new channel with peer {} for {}msat to forward HTLCs", + their_node_id, + channel_size_msat + ); + event_queue_notifier.enqueue(crate::events::LiquidityEvent::LSPS4Service( + LSPS4ServiceEvent::OpenChannel { + their_network_key: their_node_id, + amt_to_forward_msat: channel_size_msat, + channel_count, + }, + )); + } } } @@ -917,4 +1036,89 @@ where }, } } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_htlc_processing_actions_is_empty() { + let empty = HtlcProcessingActions { + forwards: vec![], + new_channel_needed_msat: None, + splice_needed: None, + channel_count: 0, + }; + assert!(empty.is_empty()); + + let with_channel = HtlcProcessingActions { + forwards: vec![], + new_channel_needed_msat: Some(100_000), + splice_needed: None, + channel_count: 0, + }; + assert!(!with_channel.is_empty()); + + let with_splice = HtlcProcessingActions { + forwards: vec![], + new_channel_needed_msat: None, + splice_needed: Some(SpliceAction { + channel_id: ChannelId::from_bytes([0; 32]), + user_channel_id: 0, + amt_to_forward_msat: 50_000, + }), + channel_count: 1, + }; + assert!(!with_splice.is_empty()); + } + + #[test] + fn test_needs_liquidity_action() { + let no_action = HtlcProcessingActions { + forwards: vec![], + new_channel_needed_msat: None, + splice_needed: None, + channel_count: 0, + }; + assert!(!no_action.needs_liquidity_action()); + + let needs_channel = HtlcProcessingActions { + forwards: vec![], + new_channel_needed_msat: Some(100_000), + splice_needed: None, + channel_count: 0, + }; + assert!(needs_channel.needs_liquidity_action()); + + let needs_splice = HtlcProcessingActions { + forwards: vec![], + new_channel_needed_msat: None, + splice_needed: Some(SpliceAction { + channel_id: ChannelId::from_bytes([0; 32]), + user_channel_id: 0, + amt_to_forward_msat: 50_000, + }), + channel_count: 1, + }; + assert!(needs_splice.needs_liquidity_action()); + } + + #[test] + fn test_cooldown_auto_expires() { + // Verify the cooldown constant is less than HTLC expiry + assert!(LIQUIDITY_COOLDOWN_SECS < HTLC_EXPIRY_THRESHOLD_SECS); + } + + #[test] + fn test_splice_action_fields() { + let action = SpliceAction { + channel_id: ChannelId::from_bytes([1; 32]), + user_channel_id: 42, + amt_to_forward_msat: 1_000_000, + }; + assert_eq!(action.channel_id, ChannelId::from_bytes([1; 32])); + assert_eq!(action.user_channel_id, 42); + assert_eq!(action.amt_to_forward_msat, 1_000_000); + } } \ No newline at end of file From 559a5aaac6c2c0d79bb94d34dc33889a6f7dc72c Mon Sep 17 00:00:00 2001 From: Martin Saposnic Date: Thu, 16 Apr 2026 10:39:40 -0500 Subject: [PATCH 114/135] chore: diagnostic logging in EventQueue (enqueue, poll, drop) --- lightning-liquidity/src/events/event_queue.rs | 34 +++++++++++++++---- 1 file changed, 28 insertions(+), 6 deletions(-) diff --git a/lightning-liquidity/src/events/event_queue.rs b/lightning-liquidity/src/events/event_queue.rs index 0d6e3a0ec54..9530846d1dc 100644 --- a/lightning-liquidity/src/events/event_queue.rs +++ b/lightning-liquidity/src/events/event_queue.rs @@ -173,11 +173,17 @@ where K::Target: KVStore, { pub fn enqueue>(&self, event: E) { + let event = event.into(); let mut state_lock = self.0.state.lock().unwrap(); if state_lock.queue.len() < MAX_EVENT_QUEUE_SIZE { - state_lock.queue.push_back(event.into()); + eprintln!( + "[EventQueue::enqueue] Pushing event (queue len before: {}): {:?}", + state_lock.queue.len(), event + ); + state_lock.queue.push_back(event); state_lock.needs_persist = true; } else { + eprintln!("[EventQueue::enqueue] DROPPED event (queue full at {}): {:?}", MAX_EVENT_QUEUE_SIZE, event); return; } } @@ -188,15 +194,25 @@ where K::Target: KVStore, { fn drop(&mut self) { - let (should_notify, should_persist_notify) = { + let (should_notify, should_persist_notify, queue_len) = { let state_lock = self.0.state.lock().unwrap(); - (!state_lock.queue.is_empty(), state_lock.needs_persist) + (!state_lock.queue.is_empty(), state_lock.needs_persist, state_lock.queue.len()) }; if should_notify { - if let Some(waker) = self.0.waker.lock().unwrap().take() { - waker.wake(); - } + let had_waker = { + let mut waker_lock = self.0.waker.lock().unwrap(); + if let Some(waker) = waker_lock.take() { + waker.wake(); + true + } else { + false + } + }; + eprintln!( + "[EventQueue::Guard::drop] Notifying: queue_len={}, had_waker={}", + queue_len, had_waker + ); #[cfg(feature = "std")] self.0.condvar.notify_one(); @@ -223,8 +239,14 @@ where ) -> core::task::Poll { let (res, should_persist_notify) = { let mut state_lock = self.0.state.lock().unwrap(); + let queue_len = state_lock.queue.len(); if let Some(event) = state_lock.queue.pop_front() { + let remaining = state_lock.queue.len(); state_lock.needs_persist = true; + eprintln!( + "[EventQueue::poll] Dequeued event (was {} in queue, {} remaining): {:?}", + queue_len, remaining, event + ); (Poll::Ready(event), true) } else { *self.0.waker.lock().unwrap() = Some(cx.waker().clone()); From c55c2779cfffcaeaa1ac1b703934958d2e4fc753 Mon Sep 17 00:00:00 2001 From: Martin Saposnic Date: Thu, 16 Apr 2026 11:28:38 -0500 Subject: [PATCH 115/135] Revert "chore: diagnostic logging in EventQueue (enqueue, poll, drop)" This reverts commit 6632c5974db9d7a754e3b1e5d1187088f105d2d4. --- lightning-liquidity/src/events/event_queue.rs | 34 ++++--------------- 1 file changed, 6 insertions(+), 28 deletions(-) diff --git a/lightning-liquidity/src/events/event_queue.rs b/lightning-liquidity/src/events/event_queue.rs index 9530846d1dc..0d6e3a0ec54 100644 --- a/lightning-liquidity/src/events/event_queue.rs +++ b/lightning-liquidity/src/events/event_queue.rs @@ -173,17 +173,11 @@ where K::Target: KVStore, { pub fn enqueue>(&self, event: E) { - let event = event.into(); let mut state_lock = self.0.state.lock().unwrap(); if state_lock.queue.len() < MAX_EVENT_QUEUE_SIZE { - eprintln!( - "[EventQueue::enqueue] Pushing event (queue len before: {}): {:?}", - state_lock.queue.len(), event - ); - state_lock.queue.push_back(event); + state_lock.queue.push_back(event.into()); state_lock.needs_persist = true; } else { - eprintln!("[EventQueue::enqueue] DROPPED event (queue full at {}): {:?}", MAX_EVENT_QUEUE_SIZE, event); return; } } @@ -194,25 +188,15 @@ where K::Target: KVStore, { fn drop(&mut self) { - let (should_notify, should_persist_notify, queue_len) = { + let (should_notify, should_persist_notify) = { let state_lock = self.0.state.lock().unwrap(); - (!state_lock.queue.is_empty(), state_lock.needs_persist, state_lock.queue.len()) + (!state_lock.queue.is_empty(), state_lock.needs_persist) }; if should_notify { - let had_waker = { - let mut waker_lock = self.0.waker.lock().unwrap(); - if let Some(waker) = waker_lock.take() { - waker.wake(); - true - } else { - false - } - }; - eprintln!( - "[EventQueue::Guard::drop] Notifying: queue_len={}, had_waker={}", - queue_len, had_waker - ); + if let Some(waker) = self.0.waker.lock().unwrap().take() { + waker.wake(); + } #[cfg(feature = "std")] self.0.condvar.notify_one(); @@ -239,14 +223,8 @@ where ) -> core::task::Poll { let (res, should_persist_notify) = { let mut state_lock = self.0.state.lock().unwrap(); - let queue_len = state_lock.queue.len(); if let Some(event) = state_lock.queue.pop_front() { - let remaining = state_lock.queue.len(); state_lock.needs_persist = true; - eprintln!( - "[EventQueue::poll] Dequeued event (was {} in queue, {} remaining): {:?}", - queue_len, remaining, event - ); (Poll::Ready(event), true) } else { *self.0.waker.lock().unwrap() = Some(cx.waker().clone()); From 219e5eb3b370a2fad8aa9415f376ec7bec133b77 Mon Sep 17 00:00:00 2001 From: Martin Saposnic Date: Tue, 21 Apr 2026 14:08:08 -0400 Subject: [PATCH 116/135] fix: rescale HTLC in-flight caps on splice promotion (#15) When a splice promotes a new FundingScope, `holder_max_htlc_value_in_flight_msat` and `counterparty_max_htlc_value_in_flight_msat` stay pinned at their pre-splice values instead of scaling with the new channel capacity. Any HTLC larger than the pre-splice cap is rejected: - on the sender with HTLCMaximum inside send_htlc - on the receiver with "Remote HTLC add would put them over our max HTLC value", which force-closes the channel Both manifestations break LSPS4 JIT splice flows where the client's embedded node first opens a small JIT channel, then splices in capacity when a larger payment arrives. The receiver force-close is particularly damaging because it destroys the channel along with any pending fees. Rescale both caps proportionally to the channel-value change on splice promotion. Also tightens the test_splice_in assertions (the previous check compared msats to sats). --- lightning/src/ln/channel.rs | 42 ++++++++++++++++++++++++++++++ lightning/src/ln/splicing_tests.rs | 14 +++++----- 2 files changed, 50 insertions(+), 6 deletions(-) diff --git a/lightning/src/ln/channel.rs b/lightning/src/ln/channel.rs index 84be091f831..2550381c884 100644 --- a/lightning/src/ln/channel.rs +++ b/lightning/src/ln/channel.rs @@ -6476,6 +6476,19 @@ fn get_holder_max_htlc_value_in_flight_msat( channel_value_satoshis * 10 * configured_percent } +fn rescale_max_htlc_value_in_flight_msat( + max_htlc_value_in_flight_msat: u64, old_channel_value_satoshis: u64, + new_channel_value_satoshis: u64, +) -> u64 { + debug_assert_ne!(old_channel_value_satoshis, 0); + if old_channel_value_satoshis == 0 { + return max_htlc_value_in_flight_msat; + } + + ((max_htlc_value_in_flight_msat as u128 * new_channel_value_satoshis as u128) + / old_channel_value_satoshis as u128) 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`] @@ -11238,12 +11251,41 @@ where .find(|funding| funding.get_funding_txid() == Some(splice_txid)) .unwrap(); let prev_funding_txid = self.funding.get_funding_txid(); + let prev_channel_value_satoshis = self.funding.get_value_satoshis(); if let Some(scid) = self.funding.short_channel_id { self.context.historical_scids.push(scid); } core::mem::swap(&mut self.funding, funding); + let new_channel_value_satoshis = self.funding.get_value_satoshis(); + let prev_holder_max_htlc_value_in_flight_msat = + self.context.holder_max_htlc_value_in_flight_msat; + let prev_counterparty_max_htlc_value_in_flight_msat = + self.context.counterparty_max_htlc_value_in_flight_msat; + self.context.holder_max_htlc_value_in_flight_msat = + rescale_max_htlc_value_in_flight_msat( + self.context.holder_max_htlc_value_in_flight_msat, + prev_channel_value_satoshis, + new_channel_value_satoshis, + ); + self.context.counterparty_max_htlc_value_in_flight_msat = + rescale_max_htlc_value_in_flight_msat( + self.context.counterparty_max_htlc_value_in_flight_msat, + prev_channel_value_satoshis, + new_channel_value_satoshis, + ); + log_info!( + logger, + "Splice promotion HTLC caps for channel {}: holder {} -> {}, counterparty {} -> {}, value {} -> {} sats", + &self.context.channel_id, + prev_holder_max_htlc_value_in_flight_msat, + self.context.holder_max_htlc_value_in_flight_msat, + prev_counterparty_max_htlc_value_in_flight_msat, + self.context.counterparty_max_htlc_value_in_flight_msat, + prev_channel_value_satoshis, + new_channel_value_satoshis, + ); // The swap above places the previous `FundingScope` into `pending_funding`. pending_splice diff --git a/lightning/src/ln/splicing_tests.rs b/lightning/src/ln/splicing_tests.rs index f9d3495b38f..3d5b094b1a7 100644 --- a/lightning/src/ln/splicing_tests.rs +++ b/lightning/src/ln/splicing_tests.rs @@ -824,15 +824,17 @@ fn test_splice_in() { 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 < initial_channel_value_sat * 1000); - let _ = send_payment(&nodes[0], &[&nodes[1]], htlc_limit_msat); + let pre_splice_htlc_limit_msat = nodes[0].node.list_channels()[0].next_outbound_htlc_limit_msat; + assert!(pre_splice_htlc_limit_msat < initial_channel_value_sat * 1000); + let _ = send_payment(&nodes[0], &[&nodes[1]], pre_splice_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 > initial_channel_value_sat); - let _ = send_payment(&nodes[0], &[&nodes[1]], htlc_limit_msat); + let post_splice_htlc_limit_msat = + nodes[0].node.list_channels()[0].next_outbound_htlc_limit_msat; + assert!(post_splice_htlc_limit_msat > pre_splice_htlc_limit_msat); + assert!(post_splice_htlc_limit_msat > initial_channel_value_sat * 1000); + let _ = send_payment(&nodes[0], &[&nodes[1]], post_splice_htlc_limit_msat); } #[test] From 5bdfe0c9ebc258b58121a43d684637a89c7a21f0 Mon Sep 17 00:00:00 2001 From: Austin Mackillop Date: Tue, 21 Apr 2026 18:17:49 +0000 Subject: [PATCH 117/135] Consolidate LSPS4 usability checks to execute time (#13) Channel usability (is_usable) was checked at four separate points: htlc_intercepted, peer_connected, process_pending_htlcs, and calculate_htlc_actions_for_peer. Each had its own deferral logic, and they had to coordinate. Move the usability check to execute_htlc_actions, right before forward_intercepted_htlc. If no usable channel exists, the forward is skipped and the HTLC stays in store for the timer to retry. htlc_intercepted, peer_connected, and process_pending_htlcs now all call process_htlcs_for_peer unconditionally. Change the pre-forward guard from is_peer_connected to has_usable_channel, which covers the disconnect+reconnect race where the peer is connected but the channel has not finished reestablishing. --- lightning-liquidity/src/lsps4/service.rs | 92 ++++++++---------------- 1 file changed, 29 insertions(+), 63 deletions(-) diff --git a/lightning-liquidity/src/lsps4/service.rs b/lightning-liquidity/src/lsps4/service.rs index 8ab023d07c7..55c59497900 100644 --- a/lightning-liquidity/src/lsps4/service.rs +++ b/lightning-liquidity/src/lsps4/service.rs @@ -218,7 +218,6 @@ where .get_cm() .list_channels_with_counterparty(&counterparty_node_id); let any_usable = channels.iter().any(|ch| ch.is_usable); - let has_channels = !channels.is_empty(); log_info!( self.logger, @@ -240,40 +239,23 @@ where ); } - if has_channels && !any_usable { - // Channels exist but none usable yet (channel_reestablish in progress). - // Defer until process_pending_htlcs picks them up. - log_info!( - self.logger, - "[LSPS4] htlc_intercepted: peer {} has {} channels but none usable, \ - deferring HTLC until channel_reestablish completes. payment_hash: {}", - counterparty_node_id, - channels.len(), - payment_hash - ); - self.htlc_store.insert(htlc).unwrap(); - } else { - // Either channels are usable, or no channels exist (need JIT open). - // calculate_htlc_actions_for_peer handles both: forward through usable - // channels, or emit OpenChannel event when no capacity exists. - let actions = self.calculate_htlc_actions_for_peer( - counterparty_node_id, - vec![htlc.clone()], - ); + let actions = self.calculate_htlc_actions_for_peer( + counterparty_node_id, + vec![htlc.clone()], + ); - if actions.needs_liquidity_action() { - self.htlc_store.insert(htlc).unwrap(); - } + if actions.needs_liquidity_action() { + self.htlc_store.insert(htlc).unwrap(); + } - log_debug!( - self.logger, - "[LSPS4] htlc_intercepted: calculated actions for peer {}: {:?}", - counterparty_node_id, - actions - ); + log_debug!( + self.logger, + "[LSPS4] htlc_intercepted: calculated actions for peer {}: {:?}", + counterparty_node_id, + actions + ); - self.execute_htlc_actions(actions, counterparty_node_id.clone()); - } + self.execute_htlc_actions(actions, counterparty_node_id.clone()); } } else { log_error!( @@ -414,28 +396,7 @@ where } } - if self.has_usable_channel(&counterparty_node_id) || channels.is_empty() { - // Either channels are usable (forward immediately) or no channels exist - // at all (process_htlcs_for_peer will trigger OpenChannel for JIT). - self.process_htlcs_for_peer(counterparty_node_id.clone(), htlcs); - } else { - // Channels exist but none usable yet (reestablish in progress). - // We still call process_htlcs_for_peer because calculate_htlc_actions - // skips non-usable channels. If existing capacity is insufficient, this - // will emit OpenChannel now rather than deferring to process_pending_htlcs - // (which would never open a channel). Forwards through existing channels - // will be empty since none are usable, so no premature forwarding occurs. - // The actual forwards happen later via channel_ready or process_pending_htlcs - // once reestablish completes. - log_info!( - self.logger, - "[LSPS4] peer_connected: {} has {} pending HTLCs, channels not yet usable \ - (reestablish in progress) - checking if new channel needed", - counterparty_node_id, - htlcs.len() - ); - self.process_htlcs_for_peer(counterparty_node_id.clone(), htlcs); - } + self.process_htlcs_for_peer(counterparty_node_id.clone(), htlcs); log_info!( self.logger, @@ -589,11 +550,14 @@ where } if self.has_usable_channel(&node_id) { - let actions = self.calculate_htlc_actions_for_peer(node_id, htlcs); - if actions.is_empty() { - continue; - } - self.execute_htlc_actions(actions, node_id); + // Channel reestablish completed — attempt to forward the deferred HTLCs. + log_info!( + self.logger, + "[LSPS4] process_pending_htlcs: forwarding {} HTLCs for peer {} (channel now usable)", + htlcs.len(), + node_id + ); + self.process_htlcs_for_peer(node_id, htlcs); } } } @@ -835,12 +799,14 @@ where // Execute forwards for forward_action in forwards { - // Re-check peer liveness right before forwarding to narrow the - // TOCTOU window between the usability check and the actual forward. - if !self.is_peer_connected(&their_node_id) { + // Re-check channel usability right before forwarding to narrow the + // TOCTOU window. Covers both peer disconnect and disconnect+reconnect + // (where the peer is connected but the channel is still reestablishing). + if !self.has_usable_channel(&their_node_id) { log_info!( self.logger, - "[LSPS4] execute_htlc_actions: peer {} disconnected before forward, skipping HTLC {:?} (will retry on next timer tick). payment_hash: {}", + "[LSPS4] execute_htlc_actions: no usable channel for peer {}, \ + skipping HTLC {:?} (will retry on next timer tick). payment_hash: {}", their_node_id, forward_action.htlc.id(), forward_action.htlc.payment_hash() From 712e751f42d7802fe3497b17a2ea3e7f348fb630 Mon Sep 17 00:00:00 2001 From: amackillop Date: Thu, 16 Apr 2026 21:18:03 +0100 Subject: [PATCH 118/135] Persist HTLCs before action calculation htlc_intercepted only persisted HTLCs when a liquidity action (splice/open) was needed. This prevents calculate_htlc_actions from returning empty actions (i.e. "do nothing, let the timer handle it") because the HTLC would never make it into the store and the timer would never see it. Move the insert before calculate_htlc_actions_for_peer so every intercepted HTLC is in the store before we decide what to do with it. execute_htlc_actions already removes the HTLC on successful forward, so the store entry is short-lived on the happy path. The cost is two extra KV store round-trips (S3 write + delete) per HTLC on the forward-only path, where previously there were none. This hits every payment since LSPS4 intercepts all of them. Unfortunately unavoidable: the insert must happen before action calculation because we don't know yet whether the result will be "forward now" or "do nothing and defer to the timer." If we wait until after and the result is "do nothing," we've already lost the HTLC. Replace the unconditional unwrap on insert with explicit error handling. On persistence failure: - Forward-only: proceed. If the forward succeeds the store entry was redundant. If persistence failed and the channel becomes unusable between calculate and execute, the HTLC is orphaned (not in store for timer retry, not forwarded) until LDK's CLTV timeout cleans it up. Requires both S3 failure and a channel state change between two adjacent calls. - Liquidity action needed: fail the HTLC back to sender. The splice/open is async and the timer needs the stored HTLC to forward once the channel is ready. Without it, we'd spend on-chain fees on a splice that never results in a forward. --- lightning-liquidity/src/lsps4/service.rs | 46 +++++++++++++++++++++--- 1 file changed, 41 insertions(+), 5 deletions(-) diff --git a/lightning-liquidity/src/lsps4/service.rs b/lightning-liquidity/src/lsps4/service.rs index 55c59497900..bd7f01c589d 100644 --- a/lightning-liquidity/src/lsps4/service.rs +++ b/lightning-liquidity/src/lsps4/service.rs @@ -239,15 +239,30 @@ where ); } + // Always persist before calculating actions. execute_htlc_actions + // removes the HTLC on successful forward. For the liquidity path + // (splice/open) the HTLC must survive in the store so the timer + // can forward it once the new channel is ready. + let persisted = match self.htlc_store.insert(htlc.clone()) { + Ok(_) => true, + Err(e) => { + log_error!( + self.logger, + "[LSPS4] htlc_intercepted: failed to persist HTLC {:?}, \ + payment_hash: {}, error: {}", + intercept_id, + payment_hash, + e + ); + false + } + }; + let actions = self.calculate_htlc_actions_for_peer( counterparty_node_id, - vec![htlc.clone()], + vec![htlc], ); - if actions.needs_liquidity_action() { - self.htlc_store.insert(htlc).unwrap(); - } - log_debug!( self.logger, "[LSPS4] htlc_intercepted: calculated actions for peer {}: {:?}", @@ -255,6 +270,27 @@ where actions ); + // Liquidity actions (splice/open) are async — the timer forwards + // the HTLC once the channel is ready. Without persistence the + // splice/open fires but the HTLC is never forwarded, wasting + // on-chain fees for a payment that times out anyway. + if !persisted && actions.needs_liquidity_action() { + log_error!( + self.logger, + "[LSPS4] htlc_intercepted: liquidity action needed but HTLC {:?} \ + not persisted, failing back to sender. payment_hash: {}", + intercept_id, + payment_hash + ); + let _ = self.channel_manager.get_cm().fail_intercepted_htlc(intercept_id); + return Ok(()); + } + + // Forward-only path is fine without persistence — the HTLC is + // consumed immediately by forward_intercepted_htlc. When persisted, + // the store entry also covers the TOCTOU race where the channel + // becomes unusable between calculate and execute; the timer retries + // from the store. self.execute_htlc_actions(actions, counterparty_node_id.clone()); } } else { From d398016c70c53695e834e5aaa630452956787704 Mon Sep 17 00:00:00 2001 From: amackillop Date: Thu, 16 Apr 2026 21:28:37 +0100 Subject: [PATCH 119/135] Prevent spurious splice on reconnect When the LSP intercepts an HTLC for an offline peer, the peer reconnects and peer_connected fires before channel_reestablish completes. All channels report is_usable=false at this point. calculate_htlc_actions_for_peer builds its capacity map from is_usable channels (empty set), finds zero capacity, then falls through to the splice candidate filter. That filter used is_channel_ready, which matches mid-reestablish channels, so it emits a splice. Once reestablish finishes, the HTLC gets forwarded through the existing channel. The splice was wasted, and the 30s liquidity cooldown it sets blocks all subsequent liquidity actions for that peer. Two fixes in calculate_htlc_actions_for_peer: 1. Early return when channels exist but none are usable. The capacity map is empty so any decision would be wrong. The HTLC stays in the store (persisted by the prior commit) and the 1Hz timer retries once channels become usable. 2. Splice candidate filter changed from is_channel_ready to is_usable. A mid-reestablish channel maye already have sufficient capacity but it's just not visible yet. Splicing into it adds unnecessary on-chain cost and a 30s cooldown for capacity that was never actually insufficient. splice_channel() would also fail if the channel does become usable in time. --- lightning-liquidity/src/lsps4/service.rs | 30 ++++++++++++++++++++---- 1 file changed, 25 insertions(+), 5 deletions(-) diff --git a/lightning-liquidity/src/lsps4/service.rs b/lightning-liquidity/src/lsps4/service.rs index bd7f01c589d..95c626f6020 100644 --- a/lightning-liquidity/src/lsps4/service.rs +++ b/lightning-liquidity/src/lsps4/service.rs @@ -706,6 +706,25 @@ where htlcs.len() ); + // Channels exist but none are usable (reestablish in progress). + // Return empty actions. We can't forward and we must not decide to splice or + // open a new channel based on stale capacity. The timer retries once usable. + if !channels.is_empty() && channel_capacity_map.is_empty() { + log_info!( + self.logger, + "[LSPS4] calculate_htlc_actions: {} has {} channels but none usable yet \ + - deferring decision", + their_node_id, + channels.len() + ); + return HtlcProcessingActions { + forwards: vec![], + new_channel_needed_msat: None, + splice_needed: None, + channel_count, + }; + } + struct ComputedHtlc { htlc: InterceptedHtlc, amount_to_forward_msat: u64, @@ -783,12 +802,13 @@ where .fold(required_amount, |acc, h| acc.saturating_add(h.amount_to_forward_msat)); // Prefer splicing into the largest usable channel over opening a new one. - // Use is_channel_ready (not is_usable) so we prefer splice even during - // channel_reestablish. splice_channel() will fail if the channel isn't - // usable yet, and the timer will retry once reestablishment completes. - let splice_candidate = channels + // Only splice into usable channels. A mid-reestablish channel may + // already have sufficient capacity that just isn't visible yet; + // splice_channel() would also reject if the channel does become + // usable in time. + let splice_candidate = channels .iter() - .filter(|c| c.is_channel_ready) + .filter(|c| c.is_usable) .max_by_key(|c| c.channel_value_satoshis); if let Some(candidate) = splice_candidate { From dc8247e22f60b5c77d7f1f2313c7932538bdb8e3 Mon Sep 17 00:00:00 2001 From: Austin Mackillop Date: Tue, 5 May 2026 13:23:13 -0400 Subject: [PATCH 120/135] Fix tests for LSPS4 and Logger generic additions (#19) LiquidityManager gained an 8th generic parameter (Logger) and KVStore targets now require KVStoreSync. Several test files and doctests were not updated after these changes landed. background-processor: The NO_LIQUIDITY_MANAGER const uses a dyn trait object for KVStore, but trait objects can only name one trait. Add a KVStoreFull supertrait combining KVStore and KVStoreSync so the dyn bound remains expressible. Add the Logger generic and KVStoreSync impl to the process_events_async doctest's stub Store type. lightning-liquidity tests: Add missing lsps4_service_config and lsps4_client_config fields (None) to config initializers in lsps0, lsps2, and lsps5 integration tests. Pass logger to LiquidityManagerSync::new calls and add TestLogger to type annotations. channel.rs tests: Remove duplicate import block left over from a bad merge in 736d3a87b. Fix field accesses that moved from ChannelContext to FundingScope (channel_value_satoshis, holder_selected_channel_reserve_satoshis). Update TestFeeEstimator construction to use the new constructor API. --- lightning-background-processor/src/lib.rs | 37 +++++++++++++------ lightning-liquidity/tests/common/mod.rs | 6 ++- .../tests/lsps0_integration_tests.rs | 2 + .../tests/lsps2_integration_tests.rs | 4 ++ .../tests/lsps5_integration_tests.rs | 6 +++ 5 files changed, 42 insertions(+), 13 deletions(-) diff --git a/lightning-background-processor/src/lib.rs b/lightning-background-processor/src/lib.rs index 445fe3277dc..3a3fbe499a9 100644 --- a/lightning-background-processor/src/lib.rs +++ b/lightning-background-processor/src/lib.rs @@ -416,6 +416,11 @@ pub const NO_ONION_MESSENGER: Option< >, > = None; +/// Supertrait combining [`KVStore`] and [`KVStoreSync`] so that a single `dyn` trait object can +/// satisfy the bounds required by [`ALiquidityManager`]. +pub trait KVStoreFull: KVStore + KVStoreSync {} +impl KVStoreFull for T {} + /// When initializing a background processor without a liquidity manager, this can be used to avoid /// specifying a concrete `LiquidityManager` type. #[cfg(not(c_bindings))] @@ -430,8 +435,8 @@ pub const NO_LIQUIDITY_MANAGER: Option< CM = &DynChannelManager, Filter = dyn chain::Filter + Send + Sync, C = &(dyn chain::Filter + Send + Sync), - KVStore = dyn lightning::util::persist::KVStore + Send + Sync, - K = &(dyn lightning::util::persist::KVStore + Send + Sync), + KVStore = dyn KVStoreFull + Send + Sync, + K = &(dyn KVStoreFull + Send + Sync), TimeProvider = dyn lightning_liquidity::utils::time::TimeProvider + Send + Sync, TP = &(dyn lightning_liquidity::utils::time::TimeProvider + Send + Sync), BroadcasterInterface = dyn lightning::chain::chaininterface::BroadcasterInterface @@ -764,6 +769,12 @@ use futures_util::{dummy_waker, Joiner, OptionalSelector, Selector, SelectorOutp /// # fn remove(&self, primary_namespace: &str, secondary_namespace: &str, key: &str, lazy: bool) -> Pin> + 'static + Send>> { todo!() } /// # fn list(&self, primary_namespace: &str, secondary_namespace: &str) -> Pin, io::Error>> + 'static + Send>> { todo!() } /// # } +/// # impl lightning::util::persist::KVStoreSync for Store { +/// # fn read(&self, primary_namespace: &str, secondary_namespace: &str, key: &str) -> io::Result> { Ok(Vec::new()) } +/// # fn write(&self, primary_namespace: &str, secondary_namespace: &str, key: &str, buf: Vec) -> io::Result<()> { Ok(()) } +/// # fn remove(&self, primary_namespace: &str, secondary_namespace: &str, key: &str, lazy: bool) -> io::Result<()> { Ok(()) } +/// # fn list(&self, primary_namespace: &str, secondary_namespace: &str) -> io::Result> { Ok(Vec::new()) } +/// # } /// # use core::time::Duration; /// # struct DefaultTimeProvider; /// # @@ -788,7 +799,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, 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>; @@ -1977,6 +1988,7 @@ mod tests { Arc, DefaultTimeProvider, Arc, + Arc, >; struct Node { @@ -2436,6 +2448,7 @@ mod tests { Arc::clone(&tx_broadcaster), None, None, + Arc::clone(&logger), ) .unwrap(), ); @@ -2808,10 +2821,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<_, _, _, _, _, _, _>) @@ -3327,10 +3340,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<_, _, _, _, _, _, _>) @@ -3554,10 +3567,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/tests/common/mod.rs b/lightning-liquidity/tests/common/mod.rs index dea987527ad..08305d236a2 100644 --- a/lightning-liquidity/tests/common/mod.rs +++ b/lightning-liquidity/tests/common/mod.rs @@ -6,7 +6,7 @@ use lightning_liquidity::{LiquidityClientConfig, LiquidityManagerSync, Liquidity 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 lightning::util::test_utils::{TestBroadcaster, TestKeysInterface, TestLogger, TestStore}; use bitcoin::Network; @@ -47,6 +47,7 @@ fn build_service_and_client_nodes<'a, 'b, 'c>( Some(service_config), None, Arc::clone(&time_provider), + service_inner.logger, ) .unwrap(); @@ -61,6 +62,7 @@ fn build_service_and_client_nodes<'a, 'b, 'c>( None, Some(client_config), time_provider, + client_inner.logger, ) .unwrap(); @@ -141,6 +143,7 @@ pub(crate) struct LiquidityNode<'a, 'b, 'c> { Arc, Arc, &'c TestBroadcaster, + &'c TestLogger, >, } @@ -155,6 +158,7 @@ impl<'a, 'b, 'c> LiquidityNode<'a, 'b, 'c> { Arc, Arc, &'c TestBroadcaster, + &'c TestLogger, >, ) -> Self { Self { inner: node, liquidity_manager } diff --git a/lightning-liquidity/tests/lsps0_integration_tests.rs b/lightning-liquidity/tests/lsps0_integration_tests.rs index 423d49785f2..ff41a140902 100644 --- a/lightning-liquidity/tests/lsps0_integration_tests.rs +++ b/lightning-liquidity/tests/lsps0_integration_tests.rs @@ -40,6 +40,7 @@ fn list_protocols_integration_test() { #[cfg(lsps1_service)] lsps1_service_config: Some(lsps1_service_config), lsps2_service_config: Some(lsps2_service_config), + lsps4_service_config: None, lsps5_service_config: Some(lsps5_service_config), advertise_service: true, }; @@ -54,6 +55,7 @@ fn list_protocols_integration_test() { #[cfg(not(lsps1_service))] lsps1_client_config: None, lsps2_client_config: Some(lsps2_client_config), + lsps4_client_config: None, lsps5_client_config: Some(lsps5_client_config), }; diff --git a/lightning-liquidity/tests/lsps2_integration_tests.rs b/lightning-liquidity/tests/lsps2_integration_tests.rs index f66b3398670..da288330fb9 100644 --- a/lightning-liquidity/tests/lsps2_integration_tests.rs +++ b/lightning-liquidity/tests/lsps2_integration_tests.rs @@ -73,6 +73,7 @@ fn build_lsps2_configs() -> ([u8; 32], LiquidityServiceConfig, LiquidityClientCo #[cfg(lsps1_service)] lsps1_service_config: None, lsps2_service_config: Some(lsps2_service_config), + lsps4_service_config: None, lsps5_service_config: None, advertise_service: true, }; @@ -81,6 +82,7 @@ fn build_lsps2_configs() -> ([u8; 32], LiquidityServiceConfig, LiquidityClientCo let client_config = LiquidityClientConfig { lsps1_client_config: None, lsps2_client_config: Some(lsps2_client_config), + lsps4_client_config: None, lsps5_client_config: None, }; @@ -1198,6 +1200,7 @@ fn lsps2_service_handler_persistence_across_restarts() { #[cfg(lsps1_service)] lsps1_service_config: None, lsps2_service_config: Some(LSPS2ServiceConfig { promise_secret }), + lsps4_service_config: None, lsps5_service_config: None, advertise_service: true, }; @@ -1342,6 +1345,7 @@ fn lsps2_service_handler_persistence_across_restarts() { Some(service_config), None, time_provider, + nodes_restart[0].logger, ) .unwrap(); diff --git a/lightning-liquidity/tests/lsps5_integration_tests.rs b/lightning-liquidity/tests/lsps5_integration_tests.rs index c08274eeb7a..635421c413b 100644 --- a/lightning-liquidity/tests/lsps5_integration_tests.rs +++ b/lightning-liquidity/tests/lsps5_integration_tests.rs @@ -59,6 +59,7 @@ pub(crate) fn lsps5_test_setup_with_kv_stores<'a, 'b, 'c>( #[cfg(lsps1_service)] lsps1_service_config: None, lsps2_service_config: None, + lsps4_service_config: None, lsps5_service_config: Some(lsps5_service_config), advertise_service: true, }; @@ -68,6 +69,7 @@ pub(crate) fn lsps5_test_setup_with_kv_stores<'a, 'b, 'c>( let client_config = LiquidityClientConfig { lsps1_client_config: None, lsps2_client_config: None, + lsps4_client_config: None, lsps5_client_config: Some(lsps5_client_config), }; @@ -243,6 +245,7 @@ pub(crate) fn lsps5_lsps2_test_setup<'a, 'b, 'c>( #[cfg(lsps1_service)] lsps1_service_config: None, lsps2_service_config: Some(lsps2_service_config), + lsps4_service_config: None, lsps5_service_config: Some(lsps5_service_config), advertise_service: true, }; @@ -252,6 +255,7 @@ pub(crate) fn lsps5_lsps2_test_setup<'a, 'b, 'c>( let client_config = LiquidityClientConfig { lsps1_client_config: None, lsps2_client_config: Some(lsps2_client_config), + lsps4_client_config: None, lsps5_client_config: Some(lsps5_client_config), }; @@ -1529,6 +1533,7 @@ fn lsps5_service_handler_persistence_across_restarts() { #[cfg(lsps1_service)] lsps1_service_config: None, lsps2_service_config: None, + lsps4_service_config: None, lsps5_service_config: Some(LSPS5ServiceConfig::default()), advertise_service: true, }; @@ -1629,6 +1634,7 @@ fn lsps5_service_handler_persistence_across_restarts() { Some(service_config), None, Arc::clone(&time_provider), + nodes_restart[0].logger, ) .unwrap(); From 89b801739a3964755afdf82c1765af66dc777c76 Mon Sep 17 00:00:00 2001 From: Austin Mackillop Date: Wed, 6 May 2026 10:29:44 -0400 Subject: [PATCH 121/135] Gate counterparty-initiated splices behind conf depth (#20) On 0-conf channels, splice_locked is sent immediately because the channel's minimum_depth is 0. This is fine when the LSP initiates the splice (it constructed the funding output), but when the counterparty initiates a splice, the LSP trusts a funding output it didn't build. The counterparty can double-spend the splice tx after the LSP has already sent splice_locked. Add ChannelHandshakeConfig::splice_minimum_depth which, when set, overrides the per-FundingScope minimum_depth for splices where is_initiator is false. The override is applied in on_tx_signatures_exchange before the funding is pushed to negotiated_candidates, so check_funding_meets_minimum_depth sees the configured depth instead of the channel's 0-conf minimum. Self-initiated splices are unaffected: the LSP constructed the funding output and the existing 0-conf behavior is correct. --- lightning/src/ln/channel.rs | 15 +- lightning/src/ln/splicing_tests.rs | 274 +++++++++++++++++++++++++++++ lightning/src/util/config.rs | 11 ++ 3 files changed, 299 insertions(+), 1 deletion(-) diff --git a/lightning/src/ln/channel.rs b/lightning/src/ln/channel.rs index 2550381c884..abe991ce88a 100644 --- a/lightning/src/ln/channel.rs +++ b/lightning/src/ln/channel.rs @@ -3047,6 +3047,7 @@ where counterparty_max_accepted_htlcs: u16, holder_max_accepted_htlcs: u16, minimum_depth: Option, + splice_minimum_depth: Option, counterparty_forwarding_info: Option, @@ -3691,6 +3692,7 @@ where 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)), minimum_depth, + splice_minimum_depth: config.channel_handshake_config.splice_minimum_depth, counterparty_forwarding_info: None, @@ -3931,6 +3933,7 @@ where counterparty_max_accepted_htlcs: 0, 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 + splice_minimum_depth: config.channel_handshake_config.splice_minimum_depth, counterparty_forwarding_info: None, @@ -9003,11 +9006,17 @@ where if let Some(pending_splice) = self.pending_splice.as_mut() { self.context.channel_state.clear_quiescent(); - if let Some(FundingNegotiation::AwaitingSignatures { mut funding, .. }) = + if let Some(FundingNegotiation::AwaitingSignatures { mut funding, is_initiator }) = pending_splice.funding_negotiation.take() { funding.funding_transaction = Some(funding_tx); + if !is_initiator { + if let Some(depth) = self.context.splice_minimum_depth { + funding.minimum_depth_override = Some(depth); + } + } + let funding_txo = funding.get_funding_txo().expect("funding outpoint should be set"); let channel_type = funding.get_channel_type().clone(); @@ -14987,6 +14996,7 @@ where (65, self.quiescent_action, option), // Added in 0.2 (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, self.context.splice_minimum_depth, option), }); Ok(()) @@ -15354,6 +15364,7 @@ where let mut pending_outbound_held_htlc_flags_opt: Option>> = None; let mut holding_cell_held_htlc_flags_opt: Option>> = None; + let mut splice_minimum_depth: Option = None; read_tlv_fields!(reader, { (0, announcement_sigs, option), @@ -15401,6 +15412,7 @@ where (65, quiescent_action, upgradable_option), // Added in 0.2 (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, splice_minimum_depth, option), }); let holder_signer = signer_provider.derive_channel_signer(channel_keys_id); @@ -15790,6 +15802,7 @@ where is_manual_broadcast: is_manual_broadcast.unwrap_or(false), interactive_tx_signing_session, + splice_minimum_depth, }, holder_commitment_point, pending_splice, diff --git a/lightning/src/ln/splicing_tests.rs b/lightning/src/ln/splicing_tests.rs index 3d5b094b1a7..423a2904c5c 100644 --- a/lightning/src/ln/splicing_tests.rs +++ b/lightning/src/ln/splicing_tests.rs @@ -2373,3 +2373,277 @@ 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_minimum_depth_for_counterparty_initiated() { + // When a 0-conf channel is spliced by the counterparty, the LSP (acceptor) should gate + // splice_locked behind the configured splice_minimum_depth rather than inheriting 0-conf. + let chanmon_cfgs = create_chanmon_cfgs(2); + let node_cfgs = create_node_cfgs(2, &chanmon_cfgs); + + // node_a (LSP): accepts 0-conf, but requires 6 confirmations for counterparty-initiated splices. + let mut lsp_config = test_default_channel_config(); + lsp_config.manually_accept_inbound_channels = true; + lsp_config.channel_handshake_limits.trust_own_funding_0conf = true; + lsp_config.channel_handshake_config.splice_minimum_depth = Some(6); + + // node_b (client): vanilla 0-conf, no splice_minimum_depth. + let mut client_config = test_default_channel_config(); + client_config.manually_accept_inbound_channels = true; + client_config.channel_handshake_limits.trust_own_funding_0conf = true; + + let node_chanmgrs = + create_node_chanmgrs(2, &node_cfgs, &[Some(lsp_config), Some(client_config)]); + let nodes = create_network(2, &node_cfgs, &node_chanmgrs); + + let node_id_a = nodes[0].node.get_our_node_id(); + let node_id_b = nodes[1].node.get_our_node_id(); + + // Open a 0-conf channel: node_b initiates, node_a accepts 0-conf. + let initial_channel_value_sat = 1_000_000; + let (funding_tx, channel_id) = + open_zero_conf_channel_with_value(&nodes[1], &nodes[0], None, initial_channel_value_sat, 0); + mine_transaction(&nodes[0], &funding_tx); + mine_transaction(&nodes[1], &funding_tx); + + // node_b (client) initiates the splice → is_initiator=true on node_b, is_initiator=false on node_a. + let coinbase_tx = provide_anchor_reserves(&nodes); + let splice_in_sats = 500_000; + let initiator_contribution = SpliceContribution::SpliceIn { + value: Amount::from_sat(splice_in_sats), + inputs: vec![FundingTxInput::new_p2wpkh(coinbase_tx, 1).unwrap()], + change_script: Some(nodes[1].wallet_source.get_change_script().unwrap()), + }; + + let new_funding_script = + complete_splice_handshake(&nodes[1], &nodes[0], channel_id, initiator_contribution.clone()); + + let initial_commit_sig = complete_interactive_funding_negotiation( + &nodes[1], + &nodes[0], + channel_id, + initiator_contribution, + new_funding_script, + ); + + // --- Sign the splice tx (custom flow for asymmetric splice_locked) --- + // The standard sign_interactive_funding_tx helper expects symmetric 0-conf behavior. + // Here, node_b (initiator) will send splice_locked, but node_a (acceptor) will not. + + // acceptor (node_a) receives initiator's commitment_signed, responds with commitment_signed + tx_signatures. + assert!(nodes[1].node.get_and_clear_pending_msg_events().is_empty()); + nodes[0].node.handle_commitment_signed(node_id_b, &initial_commit_sig); + let msg_events = nodes[0].node.get_and_clear_pending_msg_events(); + assert_eq!(msg_events.len(), 2, "{msg_events:?}"); + if let MessageSendEvent::UpdateHTLCs { ref updates, .. } = &msg_events[0] { + nodes[1].node.handle_commitment_signed(node_id_a, &updates.commitment_signed[0]); + } else { + panic!("Expected UpdateHTLCs, got {:?}", &msg_events[0]); + } + if let MessageSendEvent::SendTxSignatures { ref msg, .. } = &msg_events[1] { + nodes[1].node.handle_tx_signatures(node_id_a, msg); + } else { + panic!("Expected SendTxSignatures, got {:?}", &msg_events[1]); + } + + // initiator (node_b) signs the funding tx. + let event = get_event!(nodes[1], Event::FundingTransactionReadyForSigning); + if let Event::FundingTransactionReadyForSigning { + channel_id: cid, + counterparty_node_id, + unsigned_transaction, + .. + } = event + { + assert_eq!(cid, channel_id); + let signed_tx = nodes[1].wallet_source.sign_tx(unsigned_transaction).unwrap(); + nodes[1].node.funding_transaction_signed(&cid, &counterparty_node_id, signed_tx).unwrap(); + } + + // node_b (initiator, client) should emit TxSignatures + SpliceLocked (0-conf, no override). + let mut initiator_msgs = nodes[1].node.get_and_clear_pending_msg_events(); + assert_eq!( + initiator_msgs.len(), + 2, + "client should send TxSignatures + SpliceLocked: {initiator_msgs:?}" + ); + let initiator_splice_locked = + if let MessageSendEvent::SendTxSignatures { ref msg, .. } = &initiator_msgs[0] { + // Forward TxSignatures to acceptor (node_a). + nodes[0].node.handle_tx_signatures(node_id_b, msg); + match initiator_msgs.remove(1) { + MessageSendEvent::SendSpliceLocked { msg, .. } => msg, + ref e => panic!("Expected SendSpliceLocked, got {:?}", e), + } + } else { + panic!("Expected SendTxSignatures, got {:?}", &initiator_msgs[0]); + }; + + check_added_monitors(&nodes[1], 1); + check_added_monitors(&nodes[0], 1); + + let splice_tx = { + let mut txn = nodes[1].tx_broadcaster.txn_broadcast(); + assert_eq!(txn.len(), 1); + assert_eq!(nodes[0].tx_broadcaster.txn_broadcast(), txn); + txn.remove(0) + }; + + expect_splice_pending_event(&nodes[1], &node_id_a); + expect_splice_pending_event(&nodes[0], &node_id_b); + + // --- Key assertion: node_a (LSP/acceptor) should NOT have emitted SpliceLocked --- + let acceptor_msgs = nodes[0].node.get_and_clear_pending_msg_events(); + assert!( + acceptor_msgs.is_empty(), + "LSP should not send splice_locked before required depth, got: {acceptor_msgs:?}" + ); + + // Confirm the splice tx and mine blocks up to (but not including) the required depth. + mine_transaction(&nodes[0], &splice_tx); + mine_transaction(&nodes[1], &splice_tx); + // 1 conf from mine_transaction. We need 6 total, so connect 4 more (will be at 5 confs). + connect_blocks(&nodes[0], 4); + connect_blocks(&nodes[1], 4); + + // Still not enough depth — node_a should still not have emitted splice_locked. + // (Other messages like SendAnnouncementSignatures may appear.) + let acceptor_msgs = nodes[0].node.get_and_clear_pending_msg_events(); + assert!( + !acceptor_msgs.iter().any(|m| matches!(m, MessageSendEvent::SendSpliceLocked { .. })), + "LSP should not send splice_locked at 5 confs (need 6), got: {acceptor_msgs:?}" + ); + // Clear any announcement signatures from node_b as well. + nodes[1].node.get_and_clear_pending_msg_events(); + + // Mine one more block → 6 confirmations on both. node_a should now emit splice_locked. + connect_blocks(&nodes[0], 1); + connect_blocks(&nodes[1], 1); + + let acceptor_splice_locked = + get_event_msg!(nodes[0], MessageSendEvent::SendSpliceLocked, node_id_b); + + // Save old funding info for chain source cleanup BEFORE any splice_locked handling, + // since handle_splice_locked updates the monitor's funding outpoint. + let (prev_funding_outpoint, prev_funding_script) = nodes[0] + .chain_monitor + .chain_monitor + .get_monitor(channel_id) + .map(|monitor| (monitor.get_funding_txo(), monitor.get_funding_script())) + .unwrap(); + + // Send node_b's splice_locked (from earlier) to node_a. + nodes[0].node.handle_splice_locked(node_id_b, &initiator_splice_locked); + + // Send node_a's splice_locked to node_b. node_b responds with SendAnnouncementSignatures. + nodes[1].node.handle_splice_locked(node_id_a, &acceptor_splice_locked); + let mut b_msgs = nodes[1].node.get_and_clear_pending_msg_events(); + // node_b already sent splice_locked earlier, so it only emits SendAnnouncementSignatures. + assert_eq!(b_msgs.len(), 1, "{b_msgs:?}"); + if let MessageSendEvent::SendAnnouncementSignatures { ref msg, .. } = b_msgs.remove(0) { + nodes[0].node.handle_announcement_signatures(node_id_b, msg); + } else { + panic!("Expected SendAnnouncementSignatures, got {:?}", b_msgs); + } + + expect_channel_ready_event(&nodes[0], &node_id_b); + check_added_monitors(&nodes[0], 1); + expect_channel_ready_event(&nodes[1], &node_id_a); + check_added_monitors(&nodes[1], 1); + + // node_a now emits its own announcement_signatures + BroadcastChannelAnnouncement. + let mut a_msgs = nodes[0].node.get_and_clear_pending_msg_events(); + assert_eq!(a_msgs.len(), 2, "{a_msgs:?}"); + if let MessageSendEvent::SendAnnouncementSignatures { ref msg, .. } = a_msgs.remove(0) { + nodes[1].node.handle_announcement_signatures(node_id_a, msg); + } else { + panic!("Expected SendAnnouncementSignatures"); + } + assert!(matches!(a_msgs.remove(0), MessageSendEvent::BroadcastChannelAnnouncement { .. })); + + let mut b_msgs = nodes[1].node.get_and_clear_pending_msg_events(); + assert_eq!(b_msgs.len(), 1, "{b_msgs:?}"); + assert!(matches!(b_msgs.remove(0), MessageSendEvent::BroadcastChannelAnnouncement { .. })); + + // Cleanup chain source watches for old funding. + 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); + + // Verify the channel is usable. + send_payment(&nodes[1], &[&nodes[0]], 100_000); +} + +#[test] +fn test_splice_minimum_depth_not_applied_for_self_initiated() { + // When the LSP itself initiates a splice, the splice_minimum_depth override should NOT apply + // (is_initiator=true on LSP side), and the splice should lock immediately (0-conf). + let chanmon_cfgs = create_chanmon_cfgs(2); + let node_cfgs = create_node_cfgs(2, &chanmon_cfgs); + + // node_a (LSP): 0-conf + splice_minimum_depth. + let mut lsp_config = test_default_channel_config(); + lsp_config.manually_accept_inbound_channels = true; + lsp_config.channel_handshake_limits.trust_own_funding_0conf = true; + lsp_config.channel_handshake_config.splice_minimum_depth = Some(6); + + // node_b (client): vanilla 0-conf. + let mut client_config = test_default_channel_config(); + client_config.manually_accept_inbound_channels = true; + client_config.channel_handshake_limits.trust_own_funding_0conf = true; + + let node_chanmgrs = + create_node_chanmgrs(2, &node_cfgs, &[Some(lsp_config), Some(client_config)]); + let nodes = create_network(2, &node_cfgs, &node_chanmgrs); + + let node_id_a = nodes[0].node.get_our_node_id(); + let node_id_b = nodes[1].node.get_our_node_id(); + + // Open a 0-conf channel: node_b initiates channel, node_a accepts 0-conf. + let initial_channel_value_sat = 1_000_000; + let (funding_tx, channel_id) = + open_zero_conf_channel_with_value(&nodes[1], &nodes[0], None, initial_channel_value_sat, 0); + mine_transaction(&nodes[0], &funding_tx); + mine_transaction(&nodes[1], &funding_tx); + + // node_a (LSP) initiates the splice → is_initiator=true on node_a, override should NOT apply. + let coinbase_tx = provide_anchor_reserves(&nodes); + let splice_in_sats = 500_000; + let initiator_contribution = SpliceContribution::SpliceIn { + value: Amount::from_sat(splice_in_sats), + inputs: vec![FundingTxInput::new_p2wpkh(coinbase_tx, 0).unwrap()], + change_script: Some(nodes[0].wallet_source.get_change_script().unwrap()), + }; + + let new_funding_script = + complete_splice_handshake(&nodes[0], &nodes[1], channel_id, initiator_contribution.clone()); + + let initial_commit_sig = complete_interactive_funding_negotiation( + &nodes[0], + &nodes[1], + channel_id, + initiator_contribution, + new_funding_script, + ); + + // Use the standard helper — both sides should be 0-conf since the override doesn't apply + // to self-initiated splices. + let (_splice_tx, splice_locked) = + sign_interactive_funding_tx(&nodes[0], &nodes[1], initial_commit_sig, true); + + // node_a (LSP, initiator) DOES emit SpliceLocked immediately — the override is not applied + // because is_initiator=true. + let (splice_locked_msg, for_node_id) = splice_locked.unwrap(); + assert_eq!(for_node_id, node_id_b); + + expect_splice_pending_event(&nodes[0], &node_id_b); + expect_splice_pending_event(&nodes[1], &node_id_a); + + lock_splice(&nodes[0], &nodes[1], &splice_locked_msg, true); + + // Verify the channel is usable. + send_payment(&nodes[0], &[&nodes[1]], 100_000); +} diff --git a/lightning/src/util/config.rs b/lightning/src/util/config.rs index df7d318b015..281f3b63a8b 100644 --- a/lightning/src/util/config.rs +++ b/lightning/src/util/config.rs @@ -267,6 +267,15 @@ pub struct ChannelHandshakeConfig { /// /// [`max_htlcs`]: crate::ln::chan_utils::max_htlcs pub our_max_accepted_htlcs: u16, + + /// If set, counterparty-initiated splices on 0-conf channels will require this many + /// confirmations before we send `splice_locked`. This prevents inheriting 0-conf trust + /// on funding outputs we didn't construct. + /// + /// Has no effect on self-initiated splices or non-0-conf channels. + /// + /// Default value: `None` (splice inherits the channel's `minimum_depth`) + pub splice_minimum_depth: Option, } impl Default for ChannelHandshakeConfig { @@ -284,6 +293,7 @@ impl Default for ChannelHandshakeConfig { negotiate_anchors_zero_fee_htlc_tx: false, negotiate_anchor_zero_fee_commitments: false, our_max_accepted_htlcs: 50, + splice_minimum_depth: None, } } } @@ -307,6 +317,7 @@ impl Readable for ChannelHandshakeConfig { negotiate_anchors_zero_fee_htlc_tx: Readable::read(reader)?, negotiate_anchor_zero_fee_commitments: Readable::read(reader)?, our_max_accepted_htlcs: Readable::read(reader)?, + splice_minimum_depth: Readable::read(reader)?, }) } } From 4bc45cf6480199ffe83846719af7aead2a87183a Mon Sep 17 00:00:00 2001 From: Austin Mackillop Date: Tue, 9 Jun 2026 16:09:40 -0400 Subject: [PATCH 122/135] Add dev shell flake and justfile for local checks (#23) Introduce a Nix dev shell and a justfile so work on this fork has a reproducible toolchain and a one-command local gate, mirroring the setup in the ldk-node repo. The toolchain is pinned to stable 1.90.0 via fenix rather than tracking latest. The version is bounded on both ends: clippy must be at least 1.87 so ci/check-lint.sh can resolve the lint names it allows (older clippy hard-errors on the unknown clippy::manual_is_multiple_of), but 1.92+ adds clippy::assertions_on_constants which fires on existing code in lightning/src/chain/channelmonitor.rs and would break -D warnings on the inherited tree. 1.90.0 sits in that window and matches what CI's stable resolves to today. fenix is used over rust-overlay because it pins an exact channel by hash, so the shell can't drift. just check only compiles and runs the workspace test suite. It deliberately skips clippy and rustfmt: this branch is not clean under either of CI's gates (pre-existing dead code in lsps2/lsps4 trips check-lint.sh's -D warnings, and the tree predates the pinned rustfmt), and reformatting or de-linting code we don't own is out of scope. --- .envrc | 1 + flake.lock | 100 +++++++++++++++++++++++++++++++++++++++++++++++++++++ flake.nix | 85 +++++++++++++++++++++++++++++++++++++++++++++ justfile | 14 ++++++++ 4 files changed, 200 insertions(+) create mode 100644 .envrc create mode 100644 flake.lock create mode 100644 flake.nix create mode 100644 justfile diff --git a/.envrc b/.envrc new file mode 100644 index 00000000000..3550a30f2de --- /dev/null +++ b/.envrc @@ -0,0 +1 @@ +use flake diff --git a/flake.lock b/flake.lock new file mode 100644 index 00000000000..832c92f6e26 --- /dev/null +++ b/flake.lock @@ -0,0 +1,100 @@ +{ + "nodes": { + "fenix": { + "inputs": { + "nixpkgs": [ + "nixpkgs" + ], + "rust-analyzer-src": "rust-analyzer-src" + }, + "locked": { + "lastModified": 1780999471, + "narHash": "sha256-rY4yNlSzDY3OoYBdgjwlpVKWY/oUeOb65709i/tJbns=", + "owner": "nix-community", + "repo": "fenix", + "rev": "db976d430cbb6b0d6b300b4e22cbb28462841af2", + "type": "github" + }, + "original": { + "owner": "nix-community", + "repo": "fenix", + "type": "github" + } + }, + "flake-utils": { + "inputs": { + "systems": "systems" + }, + "locked": { + "lastModified": 1731533236, + "narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=", + "owner": "numtide", + "repo": "flake-utils", + "rev": "11707dc2f618dd54ca8739b309ec4fc024de578b", + "type": "github" + }, + "original": { + "owner": "numtide", + "repo": "flake-utils", + "type": "github" + } + }, + "nixpkgs": { + "locked": { + "lastModified": 1764521362, + "narHash": "sha256-M101xMtWdF1eSD0xhiR8nG8CXRlHmv6V+VoY65Smwf4=", + "owner": "nixos", + "repo": "nixpkgs", + "rev": "871b9fd269ff6246794583ce4ee1031e1da71895", + "type": "github" + }, + "original": { + "owner": "nixos", + "ref": "25.11", + "repo": "nixpkgs", + "type": "github" + } + }, + "root": { + "inputs": { + "fenix": "fenix", + "flake-utils": "flake-utils", + "nixpkgs": "nixpkgs" + } + }, + "rust-analyzer-src": { + "flake": false, + "locked": { + "lastModified": 1780924205, + "narHash": "sha256-5s6nPSNU1Mo/Qy90XxCTNj6/rB70zgX8VqleTdLoESA=", + "owner": "rust-lang", + "repo": "rust-analyzer", + "rev": "4fde9dd9822df453dc6f20149b8e375efbbe66c0", + "type": "github" + }, + "original": { + "owner": "rust-lang", + "ref": "nightly", + "repo": "rust-analyzer", + "type": "github" + } + }, + "systems": { + "locked": { + "lastModified": 1681028828, + "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", + "owner": "nix-systems", + "repo": "default", + "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", + "type": "github" + }, + "original": { + "owner": "nix-systems", + "repo": "default", + "type": "github" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 00000000000..0f7a38e46cb --- /dev/null +++ b/flake.nix @@ -0,0 +1,85 @@ +{ + description = "Rust Lightning Development Environment"; + + inputs = { + # Nixpkgs channel. New channels are released every 6 months. + # See: https://github.com/NixOS/nixpkgs/tags + nixpkgs.url = "github:nixos/nixpkgs/25.11"; + + # This makes it easy for the flake to be multi-platform. + # See: https://github.com/numtide/flake-utils + flake-utils.url = "github:numtide/flake-utils"; + + # Provides pinned Rust toolchains. + # See: https://github.com/nix-community/fenix + fenix = { + url = "github:nix-community/fenix"; + inputs.nixpkgs.follows = "nixpkgs"; + }; + }; + + outputs = + { + self, + nixpkgs, + flake-utils, + fenix, + }: + flake-utils.lib.eachDefaultSystem ( + system: + let + pkgs = import nixpkgs { inherit system; }; + + # Pinned stable toolchain. The version is deliberately bounded: + # * clippy must be >= 1.87 so `./ci/check-lint.sh` resolves the lints it + # `-A`s (e.g. `clippy::manual_is_multiple_of`); older clippy E0602s on the + # unknown lint name. + # * but NOT 1.92+, whose clippy grows `assertions_on_constants` (fires on + # `assert!(cfg!(fuzzing), ..)` in lightning/src/chain/channelmonitor.rs) + # and would break `-D warnings` on the pre-existing tree. + # 1.90.0 sits in that window and matches what CI's `stable` resolves to. + rust-toolchain = + (fenix.packages.${system}.toolchainOf { + channel = "1.90.0"; + sha256 = "sha256-SJwZ8g0zF2WrKDVmHrVG3pD2RGoQeo24MEXnNx5FyuI="; + }).withComponents + [ + "rustc" + "cargo" + "clippy" + "rustfmt" + "rust-src" # Needed for the rust-analyzer extension to work. + ]; + in + { + # The default development shell. Use `nix develop` or direnv to enter it. + devShells.default = pkgs.mkShell { + name = "rust-lightning-dev-shell"; + + packages = [ + rust-toolchain + ] + ++ (with pkgs; [ + just # Command runner + nodejs # JavaScript runtime, required for MCP tools + mold # Fast linker for Rust/C/C++ + pnpm # Package manager for JavaScript, required for MCP tools + stdenv.cc.cc.lib # C++ standard library for runtime + ]); + + env = { + # OpenSSL configuration for Nix + PKG_CONFIG_PATH = "${pkgs.openssl.dev}/lib/pkgconfig"; + # C++ standard library path for runtime + LD_LIBRARY_PATH = "${pkgs.stdenv.cc.cc.lib}/lib"; + }; + + shellHook = '' + echo "Rust Lightning dev shell" + rustc --version + cargo --version + ''; + }; + } + ); +} diff --git a/justfile b/justfile new file mode 100644 index 00000000000..ad4ad0626f5 --- /dev/null +++ b/justfile @@ -0,0 +1,14 @@ +default: + @just --list --unsorted + +# Run all code quality checks. TODO: Add format and linting when branch is clean +check: + RUSTFLAGS="--cfg=lsps1_service" cargo test --workspace + +# Run just the tests +test: + RUSTFLAGS="--cfg=lsps1_service" cargo test --workspace + +# Format all sources in place. +fmt: + cargo fmt --all From 8c1ad0615951ebd1e79f6a745908dafd429f064f Mon Sep 17 00:00:00 2001 From: amackillop Date: Tue, 9 Jun 2026 10:33:22 -0700 Subject: [PATCH 123/135] Add FeePolicy/FeeTier ADT and resolve_skim Introduce the fee_policy module: a FeePolicy/FeeTier ADT and a single pure resolve_skim mapping a policy plus an HTLC amount to the msat to skim. Shared foundation for upcoming work that waives the JIT-channel skim for grant recipients; nothing wires it up yet. When the skim would eat the whole HTLC, resolve_skim waives it rather than clamping to amount - 1: a residual that small may fall below htlc_minimum_msat and be rejected anyway, so clamping would bank a fee on a payment that never settles. This only triggers for a rate >= 100% or an outsized Custom base; the standard 2% never reaches it. This is also where resolve_skim deliberately diverges from the code it replaces. The service computed the proportional fee in u64, so amount * ppm overflowed for very large HTLCs and skimmed nothing, forwarding the whole HTLC for free. resolve_skim multiplies in u128 and skims correctly. At the standard 2% the result is identical for any realistic HTLC; only the previously-overflowing range changes, from "free" to "charged". Tests pin the standard tier against the legacy computation for non-overflow sizes and document the large-HTLC case. The other arms are encoding-only. ZeroFee resolves to zero; Custom { ppm, base_msat } adds a flat base to the proportional component. Custom is kept though v1 never constructs it: it's free in the TLV and documents the shape of an explicit rate. Both enums serialize via impl_writeable_tlv_based_enum! with reserved type bytes (FeeTier: Standard=0, ZeroFee=2, Custom=4; FeePolicy: Flat=0) so future variants are additive. Flat is a length-prefixed tuple variant wrapping FeeTier, letting a later commit store a policy as a TLV field that defaults cleanly on old records. --- lightning-liquidity/src/lsps4/fee_policy.rs | 200 ++++++++++++++++++++ lightning-liquidity/src/lsps4/mod.rs | 1 + 2 files changed, 201 insertions(+) create mode 100644 lightning-liquidity/src/lsps4/fee_policy.rs diff --git a/lightning-liquidity/src/lsps4/fee_policy.rs b/lightning-liquidity/src/lsps4/fee_policy.rs new file mode 100644 index 00000000000..76c0fb52f75 --- /dev/null +++ b/lightning-liquidity/src/lsps4/fee_policy.rs @@ -0,0 +1,200 @@ +// 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. + +//! Fee policy for the LSPS4 forwarding skim. +//! +//! The LSP skims a forwarding fee from every JIT-channel HTLC. This module carries the policy +//! describing *what* to skim for a given peer and resolves it to a concrete msat amount via the +//! single function [`resolve_skim`]. +//! +//! The initial version only ever constructs [`FeePolicy::Flat`], and the only tier the service +//! resolves is [`FeeTier::Standard`], so for any realistically-sized HTLC the skim matches the +//! previous hard-coded 2%. The richer arms exist so later milestones (per-peer policies, zero-fee +//! grants) are purely additive. + +use lightning::impl_writeable_tlv_based_enum; + +/// The rate at which a peer's forwarded HTLCs are skimmed. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum FeeTier { + /// Skim at the LSP's configured proportional rate (`forwarding_fee_proportional_millionths`). + Standard, + /// Never skim. Used for grant recipients whose funding we do not take a cut of. + ZeroFee, + /// Skim at an explicit rate: `base_msat` plus `ppm` proportional millionths. + Custom { + /// Proportional rate in millionths applied to the HTLC amount. + ppm: u64, + /// Flat fee in millisatoshis added on top of the proportional component. + base_msat: u64, + }, +} + +/// The discount applied to a peer. v1 only constructs [`FeePolicy::Flat`]; richer arms +/// (time-limited, volume-capped, ...) are reserved as future tags so the wire format stays +/// additive. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum FeePolicy { + /// A flat policy that applies the same [`FeeTier`] to every HTLC. + Flat(FeeTier), +} + +impl_writeable_tlv_based_enum!(FeeTier, + (0, Standard) => {}, + (2, ZeroFee) => {}, + (4, Custom) => { + (0, ppm, required), + (2, base_msat, required), + }, +); + +impl_writeable_tlv_based_enum!(FeePolicy, + {0, Flat} => (), +); + +/// Resolve a [`FeePolicy`] to the msat amount to skim from a single HTLC. +/// +/// `standard_ppm` is the LSP's configured proportional rate, used only by [`FeeTier::Standard`]. +/// +/// The skim is waived in exactly one case: when it would consume the entire HTLC. A zero-value +/// forward is rejected by the channel (`channel.rs` force-closes on a 0-msat `update_add_htlc`), +/// so skimming the whole amount would break the forward; that is the only reason we ever waive. +/// The proportional component is computed in 128-bit precision, so a very large HTLC is skimmed +/// correctly rather than (as the previous `u64` arithmetic did) overflowing and forwarding the +/// whole amount for free. +pub(crate) fn resolve_skim(policy: &FeePolicy, htlc_amount_msat: u64, standard_ppm: u64) -> u64 { + let fee_msat = match policy { + FeePolicy::Flat(FeeTier::ZeroFee) => 0, + FeePolicy::Flat(FeeTier::Standard) => proportional_fee_msat(htlc_amount_msat, standard_ppm), + FeePolicy::Flat(FeeTier::Custom { ppm, base_msat }) => { + base_msat.saturating_add(proportional_fee_msat(htlc_amount_msat, *ppm)) + }, + }; + + if fee_msat >= htlc_amount_msat { + 0 + } else { + fee_msat + } +} + +/// `amount_msat * ppm / 1_000_000`, rounded up, computed in 128-bit so it can't overflow for any +/// `u64` inputs. Saturates to `u64::MAX`, which the caller reads as "skims the whole HTLC". +fn proportional_fee_msat(amount_msat: u64, ppm: u64) -> u64 { + // `+ 999_999` before the integer divide is ceiling division: adding `denominator - 1` rounds + // the result up, so a sub-msat fee skims 1 rather than truncating to 0 (never under-skim). + let scaled = (amount_msat as u128) * (ppm as u128) + 999_999; + u64::try_from(scaled / 1_000_000).unwrap_or(u64::MAX) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::lsps4::utils::compute_forward_fee; + use lightning::util::ser::{Readable, Writeable}; + + /// The legacy inline computation the service used before `resolve_skim` existed, kept here as + /// the oracle the `Standard` tier must match for any HTLC small enough not to overflow its + /// `u64` arithmetic. + fn legacy_standard_skim(amount: u64, ppm: u64) -> u64 { + match compute_forward_fee(amount, ppm) { + Some(fee) => { + let fee = core::cmp::min(fee, amount); + if amount.saturating_sub(fee) == 0 && fee > 0 { + 0 + } else { + fee + } + }, + None => 0, + } + } + + #[test] + fn standard_matches_legacy_across_sizes() { + let ppm = 20_000; // 2% + // All sizes below the u64 overflow threshold (~9.2e14 msat at 2%), where the new 128-bit + // math and the legacy u64 math agree exactly. + for amount in [0u64, 1, 999, 1_000, 50_000, 1_000_000, 100_000_000, 1_000_000_000_000] { + let policy = FeePolicy::Flat(FeeTier::Standard); + assert_eq!( + resolve_skim(&policy, amount, ppm), + legacy_standard_skim(amount, ppm), + "mismatch at amount={amount}" + ); + } + } + + #[test] + fn large_htlc_skims_instead_of_forwarding_free() { + // 1e18 msat * 20_000 ppm overflows u64, so the legacy code skimmed nothing and forwarded + // the whole HTLC for free. The 128-bit math skims the correct 2% instead. + let amount = 1_000_000_000_000_000_000u64; + assert_eq!(legacy_standard_skim(amount, 20_000), 0); + let policy = FeePolicy::Flat(FeeTier::Standard); + assert_eq!(resolve_skim(&policy, amount, 20_000), 20_000_000_000_000_000); + } + + #[test] + fn zero_fee_never_skims() { + let policy = FeePolicy::Flat(FeeTier::ZeroFee); + for amount in [0u64, 1, 1_000, u64::MAX] { + assert_eq!(resolve_skim(&policy, amount, 1_000_000), 0); + } + } + + #[test] + fn custom_adds_base_and_proportional() { + // 1% proportional plus a flat 100 msat base. + let policy = FeePolicy::Flat(FeeTier::Custom { ppm: 10_000, base_msat: 100 }); + // 10_000 ppm of 1_000_000 = 10_000, plus 100 base = 10_100. + assert_eq!(resolve_skim(&policy, 1_000_000, 0), 10_100); + } + + #[test] + fn custom_base_only_with_zero_ppm() { + let policy = FeePolicy::Flat(FeeTier::Custom { ppm: 0, base_msat: 100 }); + assert_eq!(resolve_skim(&policy, 1_000_000, 0), 100); + } + + #[test] + fn fee_at_or_above_amount_never_skims_whole_htlc() { + // fee == amount: 1_000_000 ppm of 1_000 = 1_000 == amount -> 0. + let policy = FeePolicy::Flat(FeeTier::Standard); + assert_eq!(resolve_skim(&policy, 1_000, 1_000_000), 0); + + // fee > amount: 2_000_000 ppm of 1_000 = 2_000 > amount -> 0. + assert_eq!(resolve_skim(&policy, 1_000, 2_000_000), 0); + + // Proportional component saturates to u64::MAX, which is >= the amount -> 0. + assert_eq!(resolve_skim(&policy, u64::MAX, u64::MAX), 0); + + // A Custom base on its own large enough to swallow the HTLC -> 0. + let policy = FeePolicy::Flat(FeeTier::Custom { ppm: 0, base_msat: u64::MAX }); + assert_eq!(resolve_skim(&policy, 1_000, 0), 0); + } + + fn round_trip(value: &T) { + let bytes = value.encode(); + let decoded: T = Readable::read(&mut &bytes[..]).unwrap(); + assert_eq!(*value, decoded); + } + + #[test] + fn fee_tier_round_trips() { + round_trip(&FeeTier::Standard); + round_trip(&FeeTier::ZeroFee); + round_trip(&FeeTier::Custom { ppm: 12_345, base_msat: 678 }); + } + + #[test] + fn fee_policy_round_trips() { + round_trip(&FeePolicy::Flat(FeeTier::Standard)); + round_trip(&FeePolicy::Flat(FeeTier::ZeroFee)); + round_trip(&FeePolicy::Flat(FeeTier::Custom { ppm: 1, base_msat: 2 })); + } +} diff --git a/lightning-liquidity/src/lsps4/mod.rs b/lightning-liquidity/src/lsps4/mod.rs index c881c24affa..2354369b3b8 100644 --- a/lightning-liquidity/src/lsps4/mod.rs +++ b/lightning-liquidity/src/lsps4/mod.rs @@ -11,6 +11,7 @@ pub mod client; pub mod event; +pub mod fee_policy; pub(crate) mod htlc_store; pub mod msgs; pub(crate) mod scid_store; From 40557e98deb93e8623e7c735e87ec7efd3ffb800 Mon Sep 17 00:00:00 2001 From: amackillop Date: Tue, 9 Jun 2026 10:49:28 -0700 Subject: [PATCH 124/135] Add defaulting policy field to ScidWithPeer Persist a FeePolicy per intercept SCID record so a later milestone can look up a peer's policy at the skim site. ScidWithPeer::new still hands every record Flat(Standard), so nothing changes yet. The field uses a length-delimited TLV with a default_value of Flat(Standard) at type 4. Records written before this field existed carry only types 0 and 2, so they read back as Standard with no migration. A test encodes a copy of the old two-field layout and decodes it through the new struct to pin that default. --- lightning-liquidity/src/lsps4/scid_store.rs | 55 +++++++++++++++++++++ 1 file changed, 55 insertions(+) diff --git a/lightning-liquidity/src/lsps4/scid_store.rs b/lightning-liquidity/src/lsps4/scid_store.rs index aeff112ae0a..de3afd2740f 100644 --- a/lightning-liquidity/src/lsps4/scid_store.rs +++ b/lightning-liquidity/src/lsps4/scid_store.rs @@ -19,6 +19,7 @@ use std::ops::Deref; use crate::sync::RwLock; +use crate::lsps4::fee_policy::{FeePolicy, FeeTier}; use crate::lsps4::utils; /// The Intercepted HTLC store information will be persisted under this key. @@ -31,6 +32,7 @@ pub(crate) const INTERCEPT_SCID_STORE_PERSISTENCE_SECONDARY_NAMESPACE: &str = "" pub struct ScidWithPeer { scid: u64, peer_id: PublicKey, + policy: FeePolicy, } impl ScidWithPeer { @@ -40,6 +42,7 @@ impl ScidWithPeer { Self { scid, peer_id, + policy: FeePolicy::Flat(FeeTier::Standard), } } @@ -54,11 +57,16 @@ impl ScidWithPeer { pub fn peer_id(&self) -> PublicKey { self.peer_id } + + pub fn policy(&self) -> &FeePolicy { + &self.policy + } } impl_writeable_tlv_based!(ScidWithPeer, { (0, scid, required), (2, peer_id, required), + (4, policy, (default_value, FeePolicy::Flat(FeeTier::Standard))), }); pub struct ScidStore @@ -204,4 +212,51 @@ where L::Target: Logger, KV::Target: KVStoreSync { ); result } +} + +#[cfg(test)] +mod tests { + use super::*; + use lightning::impl_writeable_tlv_based; + + /// A copy of the pre-policy `ScidWithPeer` layout (tlv 0/2 only) used to prove that records + /// persisted before the `policy` field existed still decode, defaulting to `Flat(Standard)`. + struct LegacyScidWithPeer { + scid: u64, + peer_id: PublicKey, + } + + impl_writeable_tlv_based!(LegacyScidWithPeer, { + (0, scid, required), + (2, peer_id, required), + }); + + fn test_peer() -> PublicKey { + // The secp256k1 generator point: a valid compressed public key. + PublicKey::from_slice(&[ + 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, + ]) + .unwrap() + } + + #[test] + fn round_trips_with_policy() { + let record = ScidWithPeer::new(42, test_peer()); + let bytes = record.encode(); + let decoded = ScidWithPeer::read(&mut &bytes[..]).unwrap(); + assert_eq!(record, decoded); + assert_eq!(decoded.policy(), &FeePolicy::Flat(FeeTier::Standard)); + } + + #[test] + fn legacy_record_defaults_to_standard_policy() { + let legacy = LegacyScidWithPeer { scid: 42, peer_id: test_peer() }; + let bytes = legacy.encode(); + let decoded = ScidWithPeer::read(&mut &bytes[..]).unwrap(); + assert_eq!(decoded.scid(), 42); + assert_eq!(decoded.peer_id(), test_peer()); + assert_eq!(decoded.policy(), &FeePolicy::Flat(FeeTier::Standard)); + } } \ No newline at end of file From 8c3668c35061367c13649822913050c442e3c429 Mon Sep 17 00:00:00 2001 From: amackillop Date: Tue, 9 Jun 2026 11:09:18 -0700 Subject: [PATCH 125/135] Route the skim site through resolve_skim Replace the inline compute_forward_fee block in calculate_htlc_actions_for_peer with a single resolve_skim call against a literal Flat(Standard) policy. This is the one place the LSP decides what to skim; routing it through the pure function is what later milestones need to swap the literal for a per-peer policy lookup. Not a strict no-op: it inherits the u128 overflow fix from resolve_skim, so a >9223-BTC HTLC is now skimmed 2% instead of overflowing and forwarding free. Every realistic HTLC is unchanged. The peer's stored policy is still ignored; every forward resolves Flat(Standard) until the lookup lands. The two old log lines (overflow, skim-ate-the-HTLC) collapse into one: resolve_skim can't overflow, so a zero skim on a non-zero HTLC can only mean the fee would have eaten the whole amount. --- lightning-liquidity/src/lsps4/fee_policy.rs | 7 +++-- lightning-liquidity/src/lsps4/service.rs | 31 ++++++++------------- 2 files changed, 15 insertions(+), 23 deletions(-) diff --git a/lightning-liquidity/src/lsps4/fee_policy.rs b/lightning-liquidity/src/lsps4/fee_policy.rs index 76c0fb52f75..0d5aad32576 100644 --- a/lightning-liquidity/src/lsps4/fee_policy.rs +++ b/lightning-liquidity/src/lsps4/fee_policy.rs @@ -60,9 +60,10 @@ impl_writeable_tlv_based_enum!(FeePolicy, /// /// `standard_ppm` is the LSP's configured proportional rate, used only by [`FeeTier::Standard`]. /// -/// The skim is waived in exactly one case: when it would consume the entire HTLC. A zero-value -/// forward is rejected by the channel (`channel.rs` force-closes on a 0-msat `update_add_htlc`), -/// so skimming the whole amount would break the forward; that is the only reason we ever waive. +/// The skim is zero in two distinct cases. [`FeeTier::ZeroFee`] never skims, by design. Any other +/// tier is additionally forced to zero when its fee would consume the entire HTLC: a zero-value +/// forward is rejected by the channel (`channel.rs` force-closes on a 0-msat `update_add_htlc`), so +/// skimming the whole amount would break the forward; dropping to zero forwards it intact instead. /// The proportional component is computed in 128-bit precision, so a very large HTLC is skimmed /// correctly rather than (as the previous `u64` arithmetic did) overflowing and forwarding the /// whole amount for free. diff --git a/lightning-liquidity/src/lsps4/service.rs b/lightning-liquidity/src/lsps4/service.rs index 95c626f6020..ebd14f658c8 100644 --- a/lightning-liquidity/src/lsps4/service.rs +++ b/lightning-liquidity/src/lsps4/service.rs @@ -17,8 +17,8 @@ use crate::lsps0::ser::{ }; use crate::lsps4::event::LSPS4ServiceEvent; use crate::lsps4::htlc_store::{HTLCStore, InterceptedHtlc}; +use crate::lsps4::fee_policy::{resolve_skim, FeePolicy, FeeTier}; use crate::lsps4::scid_store::ScidStore; -use crate::lsps4::utils::compute_forward_fee; use crate::message_queue::MessageQueue; use crate::prelude::hash_map::Entry; use crate::prelude::{new_hash_map, HashMap}; @@ -740,33 +740,24 @@ where } let htlc_id = htlc.id(); - let mut fee_msat = match crate::lsps4::utils::compute_forward_fee( + let skimmed_fee_msat = resolve_skim( + &FeePolicy::Flat(FeeTier::Standard), expected_outbound_msat, self.config.forwarding_fee_proportional_millionths, - ) { - Some(fee) => core::cmp::min(fee, expected_outbound_msat), - None => { - log_error!( - self.logger, - "Overflow while computing skimmed fee for intercepted HTLC {:?}. Skipping skim.", - htlc_id - ); - 0 - }, - }; - - let mut amount_to_forward_msat = expected_outbound_msat.saturating_sub(fee_msat); - if amount_to_forward_msat == 0 && fee_msat > 0 { + ); + if skimmed_fee_msat == 0 { + // The policy here is always Flat(Standard), so a zero skim can only mean the + // fee would have consumed the entire HTLC; it is never a ZeroFee waiver yet. log_error!( self.logger, - "Skimmed fee equaled the entire HTLC amount for {:?}. Skipping skim.", + "Standard skim would have consumed the entire HTLC {:?}; forwarding the full amount.", htlc_id ); - fee_msat = 0; - amount_to_forward_msat = expected_outbound_msat; } - ComputedHtlc { htlc, amount_to_forward_msat, skimmed_fee_msat: fee_msat } + let amount_to_forward_msat = expected_outbound_msat.saturating_sub(skimmed_fee_msat); + + ComputedHtlc { htlc, amount_to_forward_msat, skimmed_fee_msat } }) .collect(); From 3e69b911418a8db0da77ff4226e8cc0ee7068e6e Mon Sep 17 00:00:00 2001 From: amackillop Date: Wed, 10 Jun 2026 09:22:21 -0700 Subject: [PATCH 126/135] Add signed fee-policy claim verification MDK-980 lets a registering node carry a signed grant for a non-standard FeePolicy that the LSP verifies locally before honouring. This is just the pure core: the claim ADT and the verifier. Nothing calls it yet, so behaviour is unchanged. A claim is a versioned TLV pair. ClaimPayload binds {scheme, node_id, policy}; SignedFeeClaim wraps the encoded payload as an opaque byte string plus a detached BIP340 signature over SHA256 of those bytes. Keeping the payload opaque means the verifier hashes exactly what was signed and never reproduces the payload's TLV layout to check the signature. TLV rather than a fixed concatenation keeps later schemes (more policy arms, an issuer key-id) additive: a new tag, not a re-issue. verify_claim takes a slice of issuer keys, not a single Option. An empty slice rejects every claim, which is how the feature stays inert until a key is configured. A non-empty slice accepts a signature from any one of the keys. That covers a no-key-id claim today and key rotation later, and per-key scoping can slot in without a breaking config change. The verifier borrows a caller-supplied secp context rather than building its own, so the verifier stays allocation free and context lifetime is the caller's call. The handler wiring that follows will own a long-lived verify context on the service and hand it in. The scheme byte is read and matched before the signature check because it selects which verification rules apply, so an unknown scheme is rejected before any crypto runs. The wire format is the contract MDK-981 (ldk-node) and MDK-982 (the TS minter) must reproduce byte-for-byte, so an in-tree test vector pins a known issuer key and claim hex. Drift in the TLV layout or the signing input fails that test instead of silently diverging across repos. The signing helper is test-only; the verifier does no I/O. --- lightning-liquidity/src/lsps4/claim.rs | 313 +++++++++++++++++++++++++ lightning-liquidity/src/lsps4/mod.rs | 1 + 2 files changed, 314 insertions(+) create mode 100644 lightning-liquidity/src/lsps4/claim.rs diff --git a/lightning-liquidity/src/lsps4/claim.rs b/lightning-liquidity/src/lsps4/claim.rs new file mode 100644 index 00000000000..609b635e410 --- /dev/null +++ b/lightning-liquidity/src/lsps4/claim.rs @@ -0,0 +1,313 @@ +// 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. + +//! Signed fee-policy claims presented by a registering node. +//! +//! A node may carry a claim that grants it a non-standard [`FeePolicy`] (for example a zero-fee +//! grant for funding we do not skim). The claim is minted off-band by an issuer the LSP trusts and +//! presented verbatim in `register_node`; the LSP verifies it locally against its configured issuer +//! keys, with no network I/O. With no issuer key configured every claim is rejected, so every peer +//! falls back to `Flat(Standard)` and behaviour is identical to a node that never sees a claim. +//! +//! The wire format is versioned and TLV-encoded rather than a fixed concatenation, so later schemes +//! (more policy arms, an issuer key-id, per-key scope) are additive: a new tag, not a re-issue. +//! The signed bytes are the *opaque* encoded [`ClaimPayload`], carried as a byte string inside +//! [`SignedFeeClaim`]. The verifier hashes exactly those bytes, so it never has to reproduce the +//! payload's TLV layout to check the signature — it only re-reads the payload after the signature +//! has already covered it. + +use bitcoin::hashes::{sha256, Hash}; +use bitcoin::hex::FromHex; +use bitcoin::secp256k1::{schnorr, Message, PublicKey, Secp256k1, Verification, XOnlyPublicKey}; + +use lightning::impl_writeable_tlv_based; +use lightning::util::ser::{Readable, Writeable}; + +use std::vec::Vec; + +use crate::lsps4::fee_policy::FeePolicy; + +/// The only signature scheme this version understands: a BIP340 Schnorr signature over secp256k1. +const CLAIM_SCHEME_V1: u8 = 1; + +/// The signed half of a claim: the issuer commits to *this node* getting *this policy*. +/// +/// Encoded and hashed as an opaque byte string by [`SignedFeeClaim`]; the verifier re-reads it only +/// after the signature has been checked over those exact bytes. +#[derive(Clone, Debug, PartialEq, Eq)] +struct ClaimPayload { + /// Signature scheme tag; only [`CLAIM_SCHEME_V1`] is accepted. + scheme: u8, + /// The node the grant is bound to. Must equal the registering peer's id. + node_id: PublicKey, + /// The policy the issuer is granting. + policy: FeePolicy, +} + +impl_writeable_tlv_based!(ClaimPayload, { + (0, scheme, required), + (2, node_id, required), + (4, policy, required), +}); + +/// The wire container: an opaque [`ClaimPayload`] blob plus the issuer's detached signature over it. +#[derive(Clone, Debug, PartialEq, Eq)] +struct SignedFeeClaim { + /// The encoded [`ClaimPayload`]. Kept as bytes so the signature is verified over exactly what + /// was signed, independent of how this verifier would re-serialize the payload. + payload: Vec, + /// BIP340 Schnorr signature over `SHA256(payload)`. + sig: schnorr::Signature, +} + +impl_writeable_tlv_based!(SignedFeeClaim, { + (0, payload, required), + (2, sig, required), +}); + +/// Why a presented claim was not honoured. Every variant resolves the peer to `Flat(Standard)`. +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) enum ClaimError { + /// The hex, the outer container, or the inner payload failed to decode. + Malformed, + /// The payload's scheme tag is not one this version understands. + UnknownScheme(u8), + /// No configured issuer key verified the signature (an empty issuer set always lands here). + BadSignature, + /// The claim is valid but bound to a different node than the one presenting it. + NodeIdMismatch, +} + +/// Verify a hex-encoded [`SignedFeeClaim`] and return the granted [`FeePolicy`]. +/// +/// A claim is accepted when it decodes, carries [`CLAIM_SCHEME_V1`], is signed by *any* of +/// `issuer_pubkeys`, and is bound to `counterparty`. An empty `issuer_pubkeys` rejects every claim +/// ([`ClaimError::BadSignature`]), which is how the feature stays inert until a key is configured. +/// +/// `scheme` is read and matched *before* the signature because it selects the verification rules: +/// a future scheme could sign a different digest, so there is no single "verify first" step that +/// works across schemes. Only `scheme == 1` (BIP340 over `SHA256(payload)`) exists today. +/// +/// Every configured key is trusted equally — any one of them may grant any [`FeePolicy`]. That is +/// fine for a single issuer. Once a second, less-trusted issuer exists (e.g. a promo key that must +/// not be able to grant a permanent zero-fee), the trust set has to carry per-key scope checked +/// against the granted policy. That gap is deliberate, not forgotten; a keyed `&[(key, scope)]` +/// shape plus a payload key-id are additive when it lands. +pub(crate) fn verify_claim( + secp_ctx: &Secp256k1, fee_claim: &str, issuer_pubkeys: &[XOnlyPublicKey], + counterparty: &PublicKey, +) -> Result { + let bytes = >::from_hex(fee_claim).map_err(|_| ClaimError::Malformed)?; + let signed = SignedFeeClaim::read(&mut &bytes[..]).map_err(|_| ClaimError::Malformed)?; + let payload = + ClaimPayload::read(&mut &signed.payload[..]).map_err(|_| ClaimError::Malformed)?; + + if payload.scheme != CLAIM_SCHEME_V1 { + return Err(ClaimError::UnknownScheme(payload.scheme)); + } + + let digest = Message::from_digest(sha256::Hash::hash(&signed.payload).to_byte_array()); + let verified = + issuer_pubkeys.iter().any(|pk| secp_ctx.verify_schnorr(&signed.sig, &digest, pk).is_ok()); + if !verified { + return Err(ClaimError::BadSignature); + } + + if payload.node_id != *counterparty { + return Err(ClaimError::NodeIdMismatch); + } + + Ok(payload.policy) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::lsps4::fee_policy::FeeTier; + use crate::lsps4::utils; + use bitcoin::secp256k1::{Keypair, SecretKey, VerifyOnly}; + + /// A throwaway verification context for the test call sites. Production threads the service's + /// long-lived context instead. + fn verify_ctx() -> Secp256k1 { + Secp256k1::verification_only() + } + + /// Fixed issuer secret used to mint the in-tree test vector. Test-only; never a real key. + const ISSUER_SECRET: [u8; 32] = [0x42; 32]; + /// Fixed node secret whose public key the test vector binds the grant to. + const NODE_SECRET: [u8; 32] = [0x11; 32]; + + fn issuer_keypair() -> Keypair { + let secp = Secp256k1::new(); + Keypair::from_secret_key(&secp, &SecretKey::from_slice(&ISSUER_SECRET).unwrap()) + } + + fn issuer_xonly() -> XOnlyPublicKey { + issuer_keypair().x_only_public_key().0 + } + + fn node_id() -> PublicKey { + let secp = Secp256k1::new(); + PublicKey::from_secret_key(&secp, &SecretKey::from_slice(&NODE_SECRET).unwrap()) + } + + /// Mint a claim the way the off-band issuer is expected to: sign `SHA256(payload)` with a + /// deterministic (no-aux-rand) BIP340 signature so the resulting hex is a stable vector. + fn mint_claim(node_id: PublicKey, policy: FeePolicy, sk: &[u8; 32]) -> String { + let secp = Secp256k1::new(); + let keypair = Keypair::from_secret_key(&secp, &SecretKey::from_slice(sk).unwrap()); + let payload = ClaimPayload { scheme: CLAIM_SCHEME_V1, node_id, policy }.encode(); + let digest = Message::from_digest(sha256::Hash::hash(&payload).to_byte_array()); + let sig = secp.sign_schnorr_no_aux_rand(&digest, &keypair); + utils::to_string(&SignedFeeClaim { payload, sig }.encode()) + } + + #[test] + fn claim_payload_round_trips() { + let payload = ClaimPayload { + scheme: CLAIM_SCHEME_V1, + node_id: node_id(), + policy: FeePolicy::Flat(FeeTier::ZeroFee), + }; + let bytes = payload.encode(); + assert_eq!(payload, ClaimPayload::read(&mut &bytes[..]).unwrap()); + } + + #[test] + fn valid_claim_yields_granted_policy() { + let claim = mint_claim(node_id(), FeePolicy::Flat(FeeTier::ZeroFee), &ISSUER_SECRET); + assert_eq!( + verify_claim(&verify_ctx(), &claim, &[issuer_xonly()], &node_id()), + Ok(FeePolicy::Flat(FeeTier::ZeroFee)) + ); + } + + #[test] + fn empty_issuer_set_rejects() { + let claim = mint_claim(node_id(), FeePolicy::Flat(FeeTier::ZeroFee), &ISSUER_SECRET); + assert_eq!( + verify_claim(&verify_ctx(), &claim, &[], &node_id()), + Err(ClaimError::BadSignature) + ); + } + + #[test] + fn wrong_issuer_key_rejects() { + let claim = mint_claim(node_id(), FeePolicy::Flat(FeeTier::ZeroFee), &ISSUER_SECRET); + // An issuer key that did not sign this claim. + let secp = Secp256k1::new(); + let other = Keypair::from_secret_key(&secp, &SecretKey::from_slice(&[0x07; 32]).unwrap()) + .x_only_public_key() + .0; + assert_eq!( + verify_claim(&verify_ctx(), &claim, &[other], &node_id()), + Err(ClaimError::BadSignature) + ); + } + + #[test] + fn forged_signature_rejects() { + // Mint with the wrong secret, then check against the real issuer's key. + let claim = mint_claim(node_id(), FeePolicy::Flat(FeeTier::ZeroFee), &[0x07; 32]); + assert_eq!( + verify_claim(&verify_ctx(), &claim, &[issuer_xonly()], &node_id()), + Err(ClaimError::BadSignature) + ); + } + + #[test] + fn node_id_mismatch_rejects() { + let claim = mint_claim(node_id(), FeePolicy::Flat(FeeTier::ZeroFee), &ISSUER_SECRET); + // A different counterparty than the one the claim is bound to. + let secp = Secp256k1::new(); + let other = + PublicKey::from_secret_key(&secp, &SecretKey::from_slice(&[0x22; 32]).unwrap()); + assert_eq!( + verify_claim(&verify_ctx(), &claim, &[issuer_xonly()], &other), + Err(ClaimError::NodeIdMismatch) + ); + } + + #[test] + fn unknown_scheme_rejects() { + let secp = Secp256k1::new(); + let keypair = issuer_keypair(); + let payload = ClaimPayload { scheme: 2, node_id: node_id(), policy: FeePolicy::Flat(FeeTier::ZeroFee) } + .encode(); + let digest = Message::from_digest(sha256::Hash::hash(&payload).to_byte_array()); + let sig = secp.sign_schnorr_no_aux_rand(&digest, &keypair); + let claim = utils::to_string(&SignedFeeClaim { payload, sig }.encode()); + assert_eq!( + verify_claim(&verify_ctx(), &claim, &[issuer_xonly()], &node_id()), + Err(ClaimError::UnknownScheme(2)) + ); + } + + #[test] + fn malformed_hex_rejects() { + assert_eq!( + verify_claim(&verify_ctx(), "zzzz", &[issuer_xonly()], &node_id()), + Err(ClaimError::Malformed) + ); + } + + #[test] + fn malformed_tlv_rejects() { + // Valid hex, but not a decodable SignedFeeClaim. + let claim = utils::to_string(&[0xff, 0xff, 0xff]); + assert_eq!( + verify_claim(&verify_ctx(), &claim, &[issuer_xonly()], &node_id()), + Err(ClaimError::Malformed) + ); + } + + #[test] + fn multi_issuer_second_key_verifies() { + // The signing key sits second in the trust set, so this exercises `.any()` past index 0 — + // the multi-issuer / key-rotation path the config shape is built for. + let claim = mint_claim(node_id(), FeePolicy::Flat(FeeTier::ZeroFee), &ISSUER_SECRET); + let secp = Secp256k1::new(); + let other = Keypair::from_secret_key(&secp, &SecretKey::from_slice(&[0x07; 32]).unwrap()) + .x_only_public_key() + .0; + assert_eq!( + verify_claim(&verify_ctx(), &claim, &[other, issuer_xonly()], &node_id()), + Ok(FeePolicy::Flat(FeeTier::ZeroFee)) + ); + } + + #[test] + fn custom_policy_survives_verify() { + // Any signed FeePolicy comes back intact, not just ZeroFee. + let policy = FeePolicy::Flat(FeeTier::Custom { ppm: 1_234, base_msat: 56 }); + let claim = mint_claim(node_id(), policy.clone(), &ISSUER_SECRET); + assert_eq!( + verify_claim(&verify_ctx(), &claim, &[issuer_xonly()], &node_id()), + Ok(policy) + ); + } + + /// The cross-repo contract the client and the issuer must reproduce byte-for-byte. The issuer + /// secret, the bound node id, and a `ZeroFee` grant mint exactly this hex; pinning it here + /// catches any drift in the TLV byte layout or the signing input. + #[test] + fn in_tree_test_vector() { + const EXPECTED_CLAIM_HEX: &str = "73002f002d2c0001010221034f355bdcb7cc0af728ef3cceb9615d90684bb5b2ca5f859ab0f0b704075871aa04040002020002408f3868ca716c39d580d8b54c8d852c22f0f1ea4a174ba13ad571e37fd182aa60e82a88c00225a3f61112804cf1e7c41bd39dbdc7fcb78e779b78423fff47d964"; + const EXPECTED_ISSUER_XONLY: &str = + "24653eac434488002cc06bbfb7f10fe18991e35f9fe4302dbea6d2353dc0ab1c"; + + assert_eq!(utils::to_string(&issuer_xonly().serialize()), EXPECTED_ISSUER_XONLY); + + let claim = mint_claim(node_id(), FeePolicy::Flat(FeeTier::ZeroFee), &ISSUER_SECRET); + assert_eq!(claim, EXPECTED_CLAIM_HEX); + assert_eq!( + verify_claim(&verify_ctx(), &claim, &[issuer_xonly()], &node_id()), + Ok(FeePolicy::Flat(FeeTier::ZeroFee)) + ); + } +} diff --git a/lightning-liquidity/src/lsps4/mod.rs b/lightning-liquidity/src/lsps4/mod.rs index 2354369b3b8..5d16b18fba0 100644 --- a/lightning-liquidity/src/lsps4/mod.rs +++ b/lightning-liquidity/src/lsps4/mod.rs @@ -9,6 +9,7 @@ //! Implementation of LSPS4: JIT Channel Negotiation specification. +pub(crate) mod claim; pub mod client; pub mod event; pub mod fee_policy; From 801fbbfd8fea46d018a92e991e0be71d61e06ce3 Mon Sep 17 00:00:00 2001 From: amackillop Date: Wed, 10 Jun 2026 09:58:45 -0700 Subject: [PATCH 127/135] Carry an optional fee claim in register_node Adds an optional fee_claim string to RegisterNodeRequest so a node can present a signed grant when it registers. The field is hex of the SignedFeeClaim bytes from the previous commit. Nothing reads it yet; the service-side verify and persist land later. serde(default, skip_serializing_if = "Option::is_none") keeps the wire backward compatible both ways. An old client that sends {} still decodes to fee_claim: None through the existing params.unwrap_or(json!({})) path, and a node with no claim emits {} rather than an explicit null, so the request bytes match today exactly when no claim is set. The client constructs the request with fee_claim: None; only a node that has been granted a claim populates it, which is wired up in a later change. --- lightning-liquidity/src/lsps4/client.rs | 2 +- lightning-liquidity/src/lsps4/msgs.rs | 37 ++++++++++++++++++++++++- 2 files changed, 37 insertions(+), 2 deletions(-) diff --git a/lightning-liquidity/src/lsps4/client.rs b/lightning-liquidity/src/lsps4/client.rs index f8e1e749896..e026eb10417 100644 --- a/lightning-liquidity/src/lsps4/client.rs +++ b/lightning-liquidity/src/lsps4/client.rs @@ -109,7 +109,7 @@ where } } - let request = LSPS4Request::RegisterNode(RegisterNodeRequest {}); + let request = LSPS4Request::RegisterNode(RegisterNodeRequest { fee_claim: None }); let msg = LSPS4Message::Request(request_id.clone(), request).into(); let mut message_queue_notifier = self.pending_messages.notifier(); message_queue_notifier.enqueue(&counterparty_node_id, msg); diff --git a/lightning-liquidity/src/lsps4/msgs.rs b/lightning-liquidity/src/lsps4/msgs.rs index 271787a793f..04a0f0ad91e 100644 --- a/lightning-liquidity/src/lsps4/msgs.rs +++ b/lightning-liquidity/src/lsps4/msgs.rs @@ -22,7 +22,13 @@ pub(crate) const LSPS4_REGISTER_NODE_METHOD_NAME: &str = "lsps4.register_node"; #[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize)] /// A request made to an LSP to register a node. -pub struct RegisterNodeRequest {} +pub struct RegisterNodeRequest { + /// An optional signed claim, lowercase-hex encoded, granting this node a + /// non-standard fee policy. Absent or unverifiable claims leave the node on + /// the standard policy. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub fee_claim: Option, +} /// A newtype that holds a `short_channel_id` in human readable format of BBBxTTTx000. #[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize)] @@ -96,3 +102,32 @@ impl From for LSPSMessage { LSPSMessage::LSPS4(message) } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::alloc::string::ToString; + + #[test] + fn register_node_request_with_claim_round_trips() { + let request = RegisterNodeRequest { fee_claim: Some("deadbeef".to_string()) }; + let json_str = r#"{"fee_claim":"deadbeef"}"#; + + assert_eq!(json_str, serde_json::json!(request).to_string()); + assert_eq!(request, serde_json::from_str(json_str).unwrap()); + } + + #[test] + fn register_node_request_without_claim_omits_field() { + let request = RegisterNodeRequest { fee_claim: None }; + + assert_eq!("{}", serde_json::json!(request).to_string()); + } + + #[test] + fn legacy_empty_object_decodes_to_no_claim() { + let request: RegisterNodeRequest = serde_json::from_str("{}").unwrap(); + + assert_eq!(request, RegisterNodeRequest { fee_claim: None }); + } +} From 46fcd71c2a0aa2744a0500bee4833f4cbd2eac6d Mon Sep 17 00:00:00 2001 From: amackillop Date: Wed, 10 Jun 2026 10:17:28 -0700 Subject: [PATCH 128/135] Retain per-peer fee policy in the SCID store The persisted ScidWithPeer already carries a policy field, but the store only built peer<->scid lookups, so the granted policy was written to disk and then lost in memory. This adds a policy_by_peer map kept in lockstep with the existing two: populated on load from the decoded records, upserted on insert, dropped on remove. A get_policy accessor reads it back. ScidWithPeer::new now takes the policy explicitly rather than defaulting it, so there is one way to build the record and the policy is always named at the construction site. add_intercepted_scid passes Flat(Standard), the path a peer with no claim takes; the register handler will pass a verified grant. The on-disk default is unchanged: it comes from the TLV default_value, not the constructor. get_policy warns as dead code until the handler reads it. Keeping policy in its own map rather than handing out whole ScidWithPeer records keeps the skim site's read to a single lookup by node id, which is all it needs. --- lightning-liquidity/src/lsps4/scid_store.rs | 78 +++++++++++++++++---- 1 file changed, 65 insertions(+), 13 deletions(-) diff --git a/lightning-liquidity/src/lsps4/scid_store.rs b/lightning-liquidity/src/lsps4/scid_store.rs index de3afd2740f..eb84b8483c8 100644 --- a/lightning-liquidity/src/lsps4/scid_store.rs +++ b/lightning-liquidity/src/lsps4/scid_store.rs @@ -37,13 +37,9 @@ pub struct ScidWithPeer { impl ScidWithPeer { pub fn new( - scid: u64, peer_id: PublicKey, + scid: u64, peer_id: PublicKey, policy: FeePolicy, ) -> Self { - Self { - scid, - peer_id, - policy: FeePolicy::Flat(FeeTier::Standard), - } + Self { scid, peer_id, policy } } pub fn store_key(&self) -> String { @@ -73,6 +69,7 @@ pub struct ScidStore where L::Target: Logger, KV::Target: KVStoreSync { peer_by_scid: RwLock>, scid_by_peer: RwLock>, + policy_by_peer: RwLock>, kv_store: KV, logger: L } @@ -112,7 +109,11 @@ where L::Target: Logger, KV::Target: KVStoreSync { let scid_by_peer = RwLock::new(HashMap::from_iter(scids.iter().map(|obj| (obj.peer_id(), obj.scid())))); - Ok(Self { peer_by_scid, scid_by_peer, kv_store, logger }) + let policy_by_peer = RwLock::new(HashMap::from_iter( + scids.iter().map(|obj| (obj.peer_id(), obj.policy().clone())), + )); + + Ok(Self { peer_by_scid, scid_by_peer, policy_by_peer, kv_store, logger }) } pub(crate) fn insert(&self, scid: ScidWithPeer) -> Result { @@ -125,8 +126,10 @@ where L::Target: Logger, KV::Target: KVStoreSync { // Then insert into the maps let mut locked_peer_by_scid = self.peer_by_scid.write().unwrap(); let mut locked_scid_by_peer = self.scid_by_peer.write().unwrap(); + let mut locked_policy_by_peer = self.policy_by_peer.write().unwrap(); let updated = locked_peer_by_scid.insert(scid.scid(), scid.peer_id().clone()).is_some(); locked_scid_by_peer.insert(scid.peer_id().clone(), scid.scid()); + locked_policy_by_peer.insert(scid.peer_id().clone(), scid.policy().clone()); log_info!( self.logger, @@ -142,10 +145,12 @@ where L::Target: Logger, KV::Target: KVStoreSync { pub(crate) fn remove(&self, scid: u64) -> Result<(), io::Error> { let mut locked_peer_by_scid = self.peer_by_scid.write().unwrap(); let mut locked_scid_by_peer = self.scid_by_peer.write().unwrap(); + let mut locked_policy_by_peer = self.policy_by_peer.write().unwrap(); let removed = locked_peer_by_scid.remove(&scid); if let Some(peer_id) = removed { locked_scid_by_peer.remove(&peer_id); + locked_policy_by_peer.remove(&peer_id); let store_key = utils::to_string(&scid.to_be_bytes()); self.kv_store .remove(INTERCEPT_SCID_STORE_PERSISTENCE_PRIMARY_NAMESPACE, INTERCEPT_SCID_STORE_PERSISTENCE_SECONDARY_NAMESPACE, &store_key, false) @@ -182,10 +187,7 @@ where L::Target: Logger, KV::Target: KVStoreSync { pub fn add_intercepted_scid( &self, scid: u64, peer_id: PublicKey, ) -> Result { - let scid = ScidWithPeer::new( - scid, - peer_id, - ); + let scid = ScidWithPeer::new(scid, peer_id, FeePolicy::Flat(FeeTier::Standard)); self.insert(scid) } @@ -212,6 +214,10 @@ where L::Target: Logger, KV::Target: KVStoreSync { ); result } + + pub fn get_policy(&self, peer_id: &PublicKey) -> Option { + self.policy_by_peer.read().unwrap().get(peer_id).cloned() + } } #[cfg(test)] @@ -243,11 +249,11 @@ mod tests { #[test] fn round_trips_with_policy() { - let record = ScidWithPeer::new(42, test_peer()); + let record = ScidWithPeer::new(42, test_peer(), FeePolicy::Flat(FeeTier::ZeroFee)); let bytes = record.encode(); let decoded = ScidWithPeer::read(&mut &bytes[..]).unwrap(); assert_eq!(record, decoded); - assert_eq!(decoded.policy(), &FeePolicy::Flat(FeeTier::Standard)); + assert_eq!(decoded.policy(), &FeePolicy::Flat(FeeTier::ZeroFee)); } #[test] @@ -259,4 +265,50 @@ mod tests { assert_eq!(decoded.peer_id(), test_peer()); assert_eq!(decoded.policy(), &FeePolicy::Flat(FeeTier::Standard)); } + + use bitcoin::secp256k1::{Secp256k1, SecretKey}; + use lightning::util::test_utils::{TestLogger, TestStore}; + use std::sync::Arc; + + fn other_peer() -> PublicKey { + PublicKey::from_secret_key(&Secp256k1::new(), &SecretKey::from_slice(&[0x24; 32]).unwrap()) + } + + fn test_store() -> ScidStore, Arc> { + ScidStore::new(Arc::new(TestStore::new(false)), Arc::new(TestLogger::new())).unwrap() + } + + #[test] + fn insert_with_policy_then_get_policy_returns_it() { + let store = test_store(); + store + .insert(ScidWithPeer::new(42, test_peer(), FeePolicy::Flat(FeeTier::ZeroFee))) + .unwrap(); + + assert_eq!(store.get_policy(&test_peer()), Some(FeePolicy::Flat(FeeTier::ZeroFee))); + assert_eq!(store.get_policy(&other_peer()), None); + } + + #[test] + fn load_rebuilds_policy_map() { + let kv_store = Arc::new(TestStore::new(false)); + { + let store = + ScidStore::new(kv_store.clone(), Arc::new(TestLogger::new())).unwrap(); + store + .insert(ScidWithPeer::new(42, test_peer(), FeePolicy::Flat(FeeTier::ZeroFee))) + .unwrap(); + } + + let reloaded = ScidStore::new(kv_store, Arc::new(TestLogger::new())).unwrap(); + assert_eq!(reloaded.get_policy(&test_peer()), Some(FeePolicy::Flat(FeeTier::ZeroFee))); + } + + #[test] + fn default_record_resolves_to_standard_policy() { + let store = test_store(); + store.add_intercepted_scid(42, test_peer()).unwrap(); + + assert_eq!(store.get_policy(&test_peer()), Some(FeePolicy::Flat(FeeTier::Standard))); + } } \ No newline at end of file From d73724f18328f3f4d51d47850a49be433ff3bb36 Mon Sep 17 00:00:00 2001 From: amackillop Date: Wed, 10 Jun 2026 10:34:31 -0700 Subject: [PATCH 129/135] Verify fee claims and persist the grant on register Wires the claim verifier into register_node. The service now holds a set of trusted issuer keys and a long-lived verification context; when a peer registers, any fee_claim it presented is verified against those keys and the granted policy is persisted onto the peer's SCID record before the response is enqueued, so the policy is in place by the time the SCID is handed out. The feature is inert by default. An empty issuer_pubkeys set short-circuits before any crypto and resolves every peer to the standard policy, byte for byte identical to today. Population of the key set is the operator's job downstream; nothing in-tree sets it. A grant is only ever upserted, never downgraded. An absent or unverifiable claim returns None and leaves an existing record untouched, so a transient miss or a malformed claim can't wipe a live grant; a brand-new record falls back to standard. This deviates from a literal "else standard" on purpose: once a node has been granted zero-fee, a dropped claim on a later re-registration must not silently restore the 2% skim. The verifier borrows the service's context rather than allocating per call, and resolve_claim_policy logs and swallows verification failures rather than failing the registration: a bad claim should cost the node its discount, not its channel. add_intercepted_scid is removed. It only built a standard-policy record, which the new persist path now does directly with the resolved policy, so the old helper had no remaining caller. --- lightning-liquidity/src/lsps4/scid_store.rs | 9 +-- lightning-liquidity/src/lsps4/service.rs | 85 ++++++++++++++++----- 2 files changed, 69 insertions(+), 25 deletions(-) diff --git a/lightning-liquidity/src/lsps4/scid_store.rs b/lightning-liquidity/src/lsps4/scid_store.rs index eb84b8483c8..e3561c30927 100644 --- a/lightning-liquidity/src/lsps4/scid_store.rs +++ b/lightning-liquidity/src/lsps4/scid_store.rs @@ -184,13 +184,6 @@ where L::Target: Logger, KV::Target: KVStoreSync { Ok(()) } - pub fn add_intercepted_scid( - &self, scid: u64, peer_id: PublicKey, - ) -> Result { - let scid = ScidWithPeer::new(scid, peer_id, FeePolicy::Flat(FeeTier::Standard)); - self.insert(scid) - } - pub fn get_peer(&self, scid: u64) -> Option { use lightning::log_debug; let result = self.peer_by_scid.read().unwrap().get(&scid).cloned(); @@ -307,7 +300,7 @@ mod tests { #[test] fn default_record_resolves_to_standard_policy() { let store = test_store(); - store.add_intercepted_scid(42, test_peer()).unwrap(); + store.insert(ScidWithPeer::new(42, test_peer(), FeePolicy::Flat(FeeTier::Standard))).unwrap(); assert_eq!(store.get_policy(&test_peer()), Some(FeePolicy::Flat(FeeTier::Standard))); } diff --git a/lightning-liquidity/src/lsps4/service.rs b/lightning-liquidity/src/lsps4/service.rs index ebd14f658c8..6b23f826107 100644 --- a/lightning-liquidity/src/lsps4/service.rs +++ b/lightning-liquidity/src/lsps4/service.rs @@ -15,10 +15,11 @@ use crate::lsps0::ser::{ JSONRPC_INTERNAL_ERROR_ERROR_CODE, JSONRPC_INTERNAL_ERROR_ERROR_MESSAGE, LSPS0_CLIENT_REJECTED_ERROR_CODE, }; +use crate::lsps4::claim::verify_claim; use crate::lsps4::event::LSPS4ServiceEvent; use crate::lsps4::htlc_store::{HTLCStore, InterceptedHtlc}; use crate::lsps4::fee_policy::{resolve_skim, FeePolicy, FeeTier}; -use crate::lsps4::scid_store::ScidStore; +use crate::lsps4::scid_store::{ScidStore, ScidWithPeer}; use crate::message_queue::MessageQueue; use crate::prelude::hash_map::Entry; use crate::prelude::{new_hash_map, HashMap}; @@ -34,7 +35,7 @@ use lightning::util::logger::{Level, Logger}; use lightning::util::persist::{KVStore, KVStoreSync}; use lightning_types::payment::PaymentHash; -use bitcoin::secp256k1::PublicKey; +use bitcoin::secp256k1::{PublicKey, Secp256k1, VerifyOnly, XOnlyPublicKey}; use core::ops::Deref; use core::sync::atomic::{AtomicUsize, Ordering}; @@ -105,6 +106,10 @@ pub struct LSPS4ServiceConfig { pub cltv_expiry_delta: u32, /// The proportional fee, in millionths, to skim from forwarded payments. pub forwarding_fee_proportional_millionths: u64, + /// Issuer keys trusted to sign fee-policy grants. Empty disables the feature: every claim is + /// rejected and every peer resolves to the standard policy. A claim signed by any one of these + /// keys is honoured. + pub issuer_pubkeys: Vec, } /// The main object allowing to send and receive LSPS4 messages. @@ -122,6 +127,8 @@ where htlc_store: HTLCStore, connected_peers: RwLock>, config: LSPS4ServiceConfig, + /// Long-lived context for verifying fee-claim signatures during registration. + secp_ctx: Secp256k1, /// Per-peer timestamped cooldown for liquidity actions (splice or channel open). /// Prevents 1Hz retry loops from process_pending_htlcs when actions fail. /// Auto-expires after LIQUIDITY_COOLDOWN_SECS. @@ -152,6 +159,7 @@ where config, logger, connected_peers: RwLock::new(HashSet::new()), + secp_ctx: Secp256k1::verification_only(), liquidity_cooldown: RwLock::new(new_hash_map()), }) } @@ -598,8 +606,56 @@ where } } + /// Resolve any fee-policy grant the registering node presented. + /// + /// Returns `None` when the feature is off (no issuer keys configured), when no claim was + /// presented, or when the claim does not verify. A `None` must never downgrade a live grant, so + /// the caller only upserts on `Some`; a new record falls back to the standard policy. + fn resolve_claim_policy( + &self, counterparty: &PublicKey, fee_claim: &Option, + ) -> Option { + if self.config.issuer_pubkeys.is_empty() { + return None; + } + let fee_claim = fee_claim.as_ref()?; + match verify_claim(&self.secp_ctx, fee_claim, &self.config.issuer_pubkeys, counterparty) { + Ok(policy) => Some(policy), + Err(e) => { + log_error!( + self.logger, + "[LSPS4] Rejected fee claim from {}: {:?}", + counterparty, + e + ); + None + }, + } + } + + /// Persist (upsert) a SCID record carrying the resolved fee policy for a peer. + fn persist_scid_policy( + &self, intercept_scid: u64, peer: &PublicKey, policy: FeePolicy, + ) -> Result<(), LightningError> { + self.scid_store + .insert(ScidWithPeer::new(intercept_scid, peer.clone(), policy)) + .map(|_| ()) + .map_err(|e| { + log_error!( + self.logger, + "[LSPS4] Failed to persist intercept SCID {} for peer {}: {}", + intercept_scid, + peer, + e + ); + LightningError { + err: format!("Failed to add intercepted SCID: {}", e), + action: ErrorAction::IgnoreAndLog(Level::Error), + } + }) + } + fn handle_register_node_request( - &self, request_id: LSPSRequestId, counterparty_node_id: &PublicKey, _params: RegisterNodeRequest, + &self, request_id: LSPSRequestId, counterparty_node_id: &PublicKey, params: RegisterNodeRequest, ) -> Result<(), LightningError> { log_info!( self.logger, @@ -608,6 +664,8 @@ where request_id ); + let granted_policy = self.resolve_claim_policy(counterparty_node_id, ¶ms.fee_claim); + let intercept_scid = match self.scid_store.get_scid(counterparty_node_id) { Some(intercept_scid) => { log_info!( @@ -616,6 +674,11 @@ where intercept_scid, counterparty_node_id ); + // Upsert a verified grant onto the existing record. An absent or invalid claim + // leaves the live policy untouched so a transient miss can't wipe a grant. + if let Some(policy) = granted_policy { + self.persist_scid_policy(intercept_scid, counterparty_node_id, policy)?; + } intercept_scid }, None => { @@ -626,20 +689,8 @@ where intercept_scid, counterparty_node_id ); - self.scid_store.add_intercepted_scid(intercept_scid, counterparty_node_id.clone()) - .map_err(|e| { - log_error!( - self.logger, - "[LSPS4] Failed to persist intercept SCID {} for peer {}: {}", - intercept_scid, - counterparty_node_id, - e - ); - LightningError { - err: format!("Failed to add intercepted SCID: {}", e), - action: ErrorAction::IgnoreAndLog(Level::Error), - } - })?; + let policy = granted_policy.unwrap_or(FeePolicy::Flat(FeeTier::Standard)); + self.persist_scid_policy(intercept_scid, counterparty_node_id, policy)?; log_info!( self.logger, "[LSPS4] Successfully stored intercept SCID {} for peer {}", From 8e8b9d2cb1e151d1d8e3aeeea3233b4a8e1efb60 Mon Sep 17 00:00:00 2001 From: amackillop Date: Wed, 10 Jun 2026 10:45:33 -0700 Subject: [PATCH 130/135] Honor the per-peer fee policy at the skim site The forwarding skim now reads the peer's granted policy from the SCID store instead of hard-coding Flat(Standard). A peer with no grant resolves to Standard, so the skim is byte-for-byte the historical 2% for everyone the issuer set has not waived. This is the read side that makes the persisted grant actually take effect; before it, a verified ZeroFee was stored and then ignored. The lookup is hoisted out of the per-HTLC loop because the policy is keyed by peer, not by HTLC, so it is one store read per batch rather than one per forward. A zero skim is now two distinct cases, so the log is split. ZeroFee is the expected path and logs at info. Any other tier skimming nothing means the fee would have consumed the whole HTLC, which keeps the error-level log: a forward of the full amount that we expected to take a cut on. Previously ZeroFee was unreachable, so the single error log was correct; now that a grant can legitimately waive the fee, the error would be noise. --- lightning-liquidity/src/lsps4/service.rs | 32 ++++++++++++++++++------ 1 file changed, 24 insertions(+), 8 deletions(-) diff --git a/lightning-liquidity/src/lsps4/service.rs b/lightning-liquidity/src/lsps4/service.rs index 6b23f826107..bd074df2351 100644 --- a/lightning-liquidity/src/lsps4/service.rs +++ b/lightning-liquidity/src/lsps4/service.rs @@ -782,6 +782,14 @@ where skimmed_fee_msat: u64, } + // The peer's granted policy, looked up once. Peers with no grant resolve to Standard, so + // the skim matches the historical 2% for everyone the issuer set hasn't waived. + let policy = self + .scid_store + .get_policy(&their_node_id) + .unwrap_or(FeePolicy::Flat(FeeTier::Standard)); + let is_zero_fee = matches!(policy, FeePolicy::Flat(FeeTier::ZeroFee)); + let mut computed_htlcs: Vec = htlcs .drain(..) .map(|htlc| { @@ -792,18 +800,26 @@ where let htlc_id = htlc.id(); let skimmed_fee_msat = resolve_skim( - &FeePolicy::Flat(FeeTier::Standard), + &policy, expected_outbound_msat, self.config.forwarding_fee_proportional_millionths, ); if skimmed_fee_msat == 0 { - // The policy here is always Flat(Standard), so a zero skim can only mean the - // fee would have consumed the entire HTLC; it is never a ZeroFee waiver yet. - log_error!( - self.logger, - "Standard skim would have consumed the entire HTLC {:?}; forwarding the full amount.", - htlc_id - ); + if is_zero_fee { + log_info!( + self.logger, + "Zero-fee policy for HTLC {:?}; forwarding the full amount.", + htlc_id + ); + } else { + // A non-zero-fee tier skimmed nothing only because the fee would have + // consumed the entire HTLC; forward it intact rather than break it. + log_error!( + self.logger, + "Skim would have consumed the entire HTLC {:?}; forwarding the full amount.", + htlc_id + ); + } } let amount_to_forward_msat = expected_outbound_msat.saturating_sub(skimmed_fee_msat); From 3a1c46ef157a2c8357836f64269d59c5a9417431 Mon Sep 17 00:00:00 2001 From: amackillop Date: Thu, 11 Jun 2026 05:51:44 -0700 Subject: [PATCH 131/135] Plumb an optional fee_claim through register_node MDK-980 added the fee_claim field to RegisterNodeRequest and the verifier that reads it, but the client still hardcoded None, so no node could ever present a claim. This lets the caller pass one. The claim rides as a per-call argument rather than a field on LSPS4ClientConfig: that config derives Copy, which an Option would break, and the value belongs to the layer above (ldk-node), which already holds it and relays it on every registration. There is a single call site, so threading it as a parameter is the smaller, honest change. The value is opaque here: a lowercase-hex signed grant the LSP alone decodes and verifies. Passing None reproduces today's behavior exactly, so nothing changes until a node is configured with a claim. --- lightning-liquidity/src/lsps4/client.rs | 78 ++++++++++++++++++++++++- 1 file changed, 75 insertions(+), 3 deletions(-) diff --git a/lightning-liquidity/src/lsps4/client.rs b/lightning-liquidity/src/lsps4/client.rs index e026eb10417..0f5b96f2ea8 100644 --- a/lightning-liquidity/src/lsps4/client.rs +++ b/lightning-liquidity/src/lsps4/client.rs @@ -83,11 +83,15 @@ where /// Requests the LSP to register the node. /// + /// `fee_claim` is an optional lowercase-hex signed grant for a non-standard fee policy; the LSP + /// verifies it against its configured issuer keys. `None` (or an unverifiable claim) leaves the + /// node on the standard policy. + /// /// The user will receive the LSP's response via an [`InvoiceParametersReady`] event. /// /// [`InvoiceParametersReady`]: crate::lsps4::event::LSPS4ClientEvent::InvoiceParametersReady pub fn register_node( - &self, counterparty_node_id: PublicKey + &self, counterparty_node_id: PublicKey, fee_claim: Option, ) -> Result { let request_id = crate::utils::generate_request_id(&self.entropy_source); @@ -109,7 +113,7 @@ where } } - let request = LSPS4Request::RegisterNode(RegisterNodeRequest { fee_claim: None }); + let request = LSPS4Request::RegisterNode(RegisterNodeRequest { fee_claim }); let msg = LSPS4Message::Request(request_id.clone(), request).into(); let mut message_queue_notifier = self.pending_messages.notifier(); message_queue_notifier.enqueue(&counterparty_node_id, msg); @@ -199,4 +203,72 @@ where } #[cfg(test)] -mod tests {} +mod tests { + use super::*; + use crate::lsps0::ser::LSPSMessage; + use bitcoin::secp256k1::{Secp256k1, SecretKey}; + use core::sync::atomic::{AtomicU64, Ordering}; + use lightning::util::persist::KVStoreSyncWrapper; + use lightning::util::test_utils::TestStore; + use lightning::util::wakers::Notifier; + use std::collections::VecDeque; + + struct CountingEntropy { + counter: AtomicU64, + } + + impl EntropySource for CountingEntropy { + fn get_secure_random_bytes(&self) -> [u8; 32] { + let counter = self.counter.fetch_add(1, Ordering::SeqCst); + let mut bytes = [0u8; 32]; + bytes[0..8].copy_from_slice(&counter.to_be_bytes()); + bytes + } + } + + type TestKVStore = Arc>>; + + fn setup() -> (LSPS4ClientHandler, TestKVStore>, Arc, PublicKey) + { + let entropy = Arc::new(CountingEntropy { counter: AtomicU64::new(1) }); + let message_queue = Arc::new(MessageQueue::new(Arc::new(Notifier::new()))); + let kv_store = Arc::new(KVStoreSyncWrapper(Arc::new(TestStore::new(false)))); + let event_queue = + Arc::new(EventQueue::new(VecDeque::new(), kv_store, Arc::new(Notifier::new()))); + let client = LSPS4ClientHandler::new( + entropy, + Arc::clone(&message_queue), + event_queue, + LSPS4ClientConfig::default(), + ); + let peer = PublicKey::from_secret_key(&Secp256k1::new(), &SecretKey::from_slice(&[42u8; 32]).unwrap()); + (client, message_queue, peer) + } + + /// The single enqueued message must be a `register_node` request; returns its `fee_claim`. + fn sole_request_fee_claim(message_queue: &MessageQueue) -> Option { + let mut pending = message_queue.get_and_clear_pending_msgs(); + assert_eq!(pending.len(), 1); + match pending.pop().unwrap().1 { + LSPSMessage::LSPS4(LSPS4Message::Request(_, LSPS4Request::RegisterNode(req))) => { + req.fee_claim + }, + other => panic!("expected a register_node request, got {:?}", other), + } + } + + #[test] + fn register_node_carries_the_claim() { + let (client, message_queue, peer) = setup(); + let claim = "deadbeef".to_string(); + client.register_node(peer, Some(claim.clone())).unwrap(); + assert_eq!(sole_request_fee_claim(&message_queue), Some(claim)); + } + + #[test] + fn register_node_without_a_claim_omits_it() { + let (client, message_queue, peer) = setup(); + client.register_node(peer, None).unwrap(); + assert_eq!(sole_request_fee_claim(&message_queue), None); + } +} From f0308f89a8860a112a9d6f3119d5c33ef652b436 Mon Sep 17 00:00:00 2001 From: amackillop Date: Fri, 7 Aug 2026 12:24:50 -0700 Subject: [PATCH 132/135] Fix upstream lsps5 test for LSPS4 constructor and config additions Upstream 0.2.3 added lsps5_integration_tests cases that construct LiquidityManagerSync directly. Our LSPS4 work extends the liquidity config structs and appends a logger parameter to the constructor, so the new upstream call sites no longer compile. Same role as the earlier 'Fix tests for LSPS4 and Logger generic additions' commit, applied to the v0.2.5 base. --- lightning-liquidity/tests/lsps5_integration_tests.rs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/lightning-liquidity/tests/lsps5_integration_tests.rs b/lightning-liquidity/tests/lsps5_integration_tests.rs index 635421c413b..22b63cd0ee7 100644 --- a/lightning-liquidity/tests/lsps5_integration_tests.rs +++ b/lightning-liquidity/tests/lsps5_integration_tests.rs @@ -1759,12 +1759,14 @@ fn lsps5_service_persist_resets_in_flight_counter_on_io_error() { #[cfg(lsps1_service)] lsps1_service_config: None, lsps2_service_config: None, + lsps4_service_config: None, lsps5_service_config: Some(LSPS5ServiceConfig::default()), advertise_service: true, }; let client_config = LiquidityClientConfig { lsps1_client_config: None, lsps2_client_config: None, + lsps4_client_config: None, lsps5_client_config: Some(LSPS5ClientConfig::default()), }; let time_provider: Arc = Arc::new(DefaultTimeProvider); @@ -1785,6 +1787,7 @@ fn lsps5_service_persist_resets_in_flight_counter_on_io_error() { Some(service_config), None, Arc::clone(&time_provider), + nodes[0].logger, ) .unwrap(); @@ -1799,6 +1802,7 @@ fn lsps5_service_persist_resets_in_flight_counter_on_io_error() { None, Some(client_config), Arc::clone(&time_provider), + nodes[1].logger, ) .unwrap(); From d6f87a44d0ee77a93094fca58dd5bd7349494a06 Mon Sep 17 00:00:00 2001 From: amackillop Date: Fri, 7 Aug 2026 13:47:30 -0700 Subject: [PATCH 133/135] Restore splice negotiation with clients on the legacy prototype bit The v0.2.5 rebase dropped the MDK-799 dual-advertise commits because upstream 0.2.2 renumbered SplicePrototype to bit 63, turning their clear_splicing() ACINQ carve-out into a production-bit strip. That left bit-155-only clients unable to splice: we neither advertised a bit they recognize nor recognized the bit they advertise, so LSPS4 would fall back to opening a second channel on every liquidity top-up. We have to assume such clients exist: client version telemetry (MDK-424, lightning-node#824) has not shipped, and the legacy e2e jobs cannot distinguish a splice from the channel-open fallback by design. Reintroduce the bit under a new SplicePrototypeLegacy feature (155 is unused upstream now) and accept it at the splice_channel gate. The wire protocol is unchanged between v0.2 and v0.2.5 (msgs.rs and interactivetxs.rs are byte-identical across the patch series), so a 0.2.5 LSP can splice with a v0.2-fork client; only the negotiation bit moved. The ACINQ carve-out returns unchanged: eclair rejects an Init carrying both 155 (their pre-standard splice bit) and 63. Sunset: once #824 ships and shows no bit-155 clients, drop the define_feature block, the set_splicing_legacy_optional() call, the strip, and the gate's legacy arm. --- lightning-types/src/features.rs | 15 ++++++-- lightning/src/ln/channelmanager.rs | 58 ++++++++++++++++++++++++++++-- lightning/src/ln/splicing_tests.rs | 1 + 3 files changed, 69 insertions(+), 5 deletions(-) diff --git a/lightning-types/src/features.rs b/lightning-types/src/features.rs index de8c97e6da6..8cc0782ffc2 100644 --- a/lightning-types/src/features.rs +++ b/lightning-types/src/features.rs @@ -174,7 +174,7 @@ mod sealed { // Byte 18 , // Byte 19 - HtlcHold, + HtlcHold | SplicePrototypeLegacy, ] ); define_context!( @@ -203,7 +203,7 @@ mod sealed { // Byte 18 , // Byte 19 - HtlcHold, + HtlcHold | SplicePrototypeLegacy, // Byte 20 - 31 ,,,,,,,,,,,, // Byte 32 @@ -740,6 +740,17 @@ mod sealed { supports_splicing, requires_splicing ); + define_feature!( + 155, // The splicing bit LDK used before 0.2.2 moved it to 63 + SplicePrototypeLegacy, + [InitContext, NodeContext], + "Feature flags for channel splicing with peers on the pre-0.2.2 prototype bit.", + set_splicing_legacy_optional, + set_splicing_legacy_required, + clear_splicing_legacy, + supports_splicing_legacy, + requires_splicing_legacy + ); define_feature!( 259, DnsResolver, diff --git a/lightning/src/ln/channelmanager.rs b/lightning/src/ln/channelmanager.rs index d1dc0a866f0..1aed643a214 100644 --- a/lightning/src/ln/channelmanager.rs +++ b/lightning/src/ln/channelmanager.rs @@ -4784,7 +4784,11 @@ where }; let mut peer_state = peer_state_mutex.lock().unwrap(); - if !peer_state.latest_features.supports_splicing() { + // Accept the legacy prototype bit too: un-upgraded clients advertise + // splicing on bit 155 and speak the same splice protocol. See MDK-799. + if !peer_state.latest_features.supports_splicing() + && !peer_state.latest_features.supports_splicing_legacy() + { return Err(APIError::ChannelUnavailable { err: "Peer does not support splicing".to_owned(), }); @@ -13786,8 +13790,10 @@ where provided_node_features(&self.config.read().unwrap()) } - fn provided_init_features(&self, _their_init_features: PublicKey) -> InitFeatures { - provided_init_features(&self.config.read().unwrap()) + fn provided_init_features(&self, their_node_id: PublicKey) -> InitFeatures { + let mut features = provided_init_features(&self.config.read().unwrap()); + strip_acinq_splice_legacy(&mut features, &their_node_id); + features } #[rustfmt::skip] @@ -15776,6 +15782,26 @@ pub(crate) fn provided_channel_type_features(config: &UserConfig) -> ChannelType ChannelTypeFeatures::from_init(&provided_init_features(config)) } +/// ACINQ mainnet node id. Eclair chokes on an `Init` advertising both the +/// legacy splice prototype bit (155, which eclair used for its own +/// pre-standard splicing) and the production bit (63), so strip the legacy +/// bit when peering with this specific node. ACINQ is on bit 63. The +/// carve-out is intentionally per-peer `Init` only — gossip +/// `node_announcement`s are broadcast, not per-peer. See MDK-799. +const ACINQ_MAINNET_NODE_ID: [u8; 33] = [ + 0x03, 0x86, 0x4e, 0xf0, 0x25, 0xfd, 0xe8, 0xfb, 0x58, 0x7d, 0x98, 0x91, 0x86, 0xce, 0x6a, 0x4a, + 0x18, 0x68, 0x95, 0xee, 0x44, 0xa9, 0x26, 0xbf, 0xc3, 0x70, 0xe2, 0xc3, 0x66, 0x59, 0x7a, 0x3f, + 0x8f, +]; + +/// If `their_node_id` is the ACINQ mainnet node, clear the legacy splice +/// prototype (bit 155) feature. See [`ACINQ_MAINNET_NODE_ID`] for context. +fn strip_acinq_splice_legacy(features: &mut InitFeatures, their_node_id: &PublicKey) { + if their_node_id.serialize() == ACINQ_MAINNET_NODE_ID { + features.clear_splicing_legacy(); + } +} + /// Fetches the set of [`InitFeatures`] flags that are provided by or required by /// [`ChannelManager`]. pub fn provided_init_features(config: &UserConfig) -> InitFeatures { @@ -15800,6 +15826,10 @@ pub fn provided_init_features(config: &UserConfig) -> InitFeatures { features.set_simple_close_optional(); features.set_quiescence_optional(); features.set_splicing_optional(); + // Dual-advertise the pre-0.2.2 prototype bit so un-upgraded clients still + // negotiate splicing. Stripped per-peer for ACINQ in the BaseMessageHandler + // impl. See MDK-799. + features.set_splicing_legacy_optional(); if config.channel_handshake_config.negotiate_anchors_zero_fee_htlc_tx { features.set_anchors_zero_fee_htlc_tx_optional(); @@ -18508,6 +18538,28 @@ mod tests { use bitcoin::secp256k1::{PublicKey, Secp256k1, SecretKey}; use core::sync::atomic::Ordering; + #[test] + fn test_splice_legacy_dual_advertise_and_acinq_strip() { + let config = crate::util::config::UserConfig::default(); + + let mut features = super::provided_init_features(&config); + assert!(features.supports_splicing()); + assert!(features.supports_splicing_legacy()); + + // ACINQ gets the legacy bit stripped but keeps the production bit. + let acinq = PublicKey::from_slice(&super::ACINQ_MAINNET_NODE_ID).unwrap(); + super::strip_acinq_splice_legacy(&mut features, &acinq); + assert!(features.supports_splicing()); + assert!(!features.supports_splicing_legacy()); + + // Anyone else keeps both bits. + let mut features = super::provided_init_features(&config); + let other = PublicKey::from_slice(&[2; 33]).unwrap(); + super::strip_acinq_splice_legacy(&mut features, &other); + assert!(features.supports_splicing()); + assert!(features.supports_splicing_legacy()); + } + #[test] #[rustfmt::skip] fn test_notify_limits() { diff --git a/lightning/src/ln/splicing_tests.rs b/lightning/src/ln/splicing_tests.rs index 423a2904c5c..e60ecf7dddc 100644 --- a/lightning/src/ln/splicing_tests.rs +++ b/lightning/src/ln/splicing_tests.rs @@ -38,6 +38,7 @@ fn test_splicing_not_supported_api_error() { let node_cfgs = create_node_cfgs(2, &chanmon_cfgs); let mut features = provided_init_features(&test_default_channel_config()); features.clear_splicing(); + features.clear_splicing_legacy(); *node_cfgs[0].override_init_features.borrow_mut() = Some(features); let node_chanmgrs = create_node_chanmgrs(2, &node_cfgs, &[None, None]); let nodes = create_network(2, &node_cfgs, &node_chanmgrs); From fc31afda7a672d7fa96880b44ff0dfe62cb9e784 Mon Sep 17 00:00:00 2001 From: amackillop Date: Mon, 10 Aug 2026 20:36:01 -0700 Subject: [PATCH 134/135] Align nixpkgs on the nixos-26.05 channel branch Part of pinning every repo the kitaebot fleet builds to one nixpkgs rev so devshell closures share store paths in the bot VM. The old input pinned the 25.11 release tag, which never moves and so never picks up channel backports; nixos-26.05 is the maintained release branch. The 1.90.0 toolchain window (clippy >= 1.87 for check-lint's lint names, < 1.92 for assertions_on_constants) is untouched; only the fenix rev moves, and toolchainOf pins the version by sha256. --- flake.lock | 24 ++++++++++++------------ flake.nix | 2 +- 2 files changed, 13 insertions(+), 13 deletions(-) diff --git a/flake.lock b/flake.lock index 832c92f6e26..fec6727ceba 100644 --- a/flake.lock +++ b/flake.lock @@ -8,11 +8,11 @@ "rust-analyzer-src": "rust-analyzer-src" }, "locked": { - "lastModified": 1780999471, - "narHash": "sha256-rY4yNlSzDY3OoYBdgjwlpVKWY/oUeOb65709i/tJbns=", + "lastModified": 1786348189, + "narHash": "sha256-tnTpFky6/GeXVMBO+hLJK2jWS4J8TYloL+rqRME9gAk=", "owner": "nix-community", "repo": "fenix", - "rev": "db976d430cbb6b0d6b300b4e22cbb28462841af2", + "rev": "a0b1aef63d7e9c5568958e0d90f6fe48f0f4a7a2", "type": "github" }, "original": { @@ -41,16 +41,16 @@ }, "nixpkgs": { "locked": { - "lastModified": 1764521362, - "narHash": "sha256-M101xMtWdF1eSD0xhiR8nG8CXRlHmv6V+VoY65Smwf4=", - "owner": "nixos", + "lastModified": 1786313170, + "narHash": "sha256-9BG7OgUWdu0ONDO5X2q6+K4bsuBITkX/3W4nNJu1Ito=", + "owner": "NixOS", "repo": "nixpkgs", - "rev": "871b9fd269ff6246794583ce4ee1031e1da71895", + "rev": "fcb8fcd6bf2d0adecae5bd491afaaaf8311b758d", "type": "github" }, "original": { - "owner": "nixos", - "ref": "25.11", + "owner": "NixOS", + "ref": "nixos-26.05", "repo": "nixpkgs", "type": "github" } @@ -65,11 +65,11 @@ "rust-analyzer-src": { "flake": false, "locked": { - "lastModified": 1780924205, - "narHash": "sha256-5s6nPSNU1Mo/Qy90XxCTNj6/rB70zgX8VqleTdLoESA=", + "lastModified": 1786285255, + "narHash": "sha256-zJukKxGKLxcO7E6+P49Qe6XaSbbyGG44kFvQYLWaD3M=", "owner": "rust-lang", "repo": "rust-analyzer", - "rev": "4fde9dd9822df453dc6f20149b8e375efbbe66c0", + "rev": "57411b3a0fb0181ed5965f6541d5ed39a9d79d4c", "type": "github" }, "original": { diff --git a/flake.nix b/flake.nix index 0f7a38e46cb..f2b3835a1af 100644 --- a/flake.nix +++ b/flake.nix @@ -4,7 +4,7 @@ inputs = { # Nixpkgs channel. New channels are released every 6 months. # See: https://github.com/NixOS/nixpkgs/tags - nixpkgs.url = "github:nixos/nixpkgs/25.11"; + nixpkgs.url = "github:NixOS/nixpkgs/nixos-26.05"; # This makes it easy for the flake to be multi-platform. # See: https://github.com/numtide/flake-utils From 66a73662bde9a57b7ac0ab5251b0d07d422899bc Mon Sep 17 00:00:00 2001 From: amackillop Date: Tue, 11 Aug 2026 11:17:52 -0700 Subject: [PATCH 135/135] Retransmit splice_locked to peers resuming signing A peer that lost our tx_signatures (crashed before processing or persisting them) reestablishes with next_funding set for the splice. Its splice funding is still in funding_negotiation at that point, so it cannot apply the my_current_funding_locked TLV from our channel_reestablish: the inference only matches negotiated funding, and the TLV is processed exactly once. If we had already locked the splice, nothing ever conveys our lock again. The peer completes signing via the retransmitted signatures, locks, and then waits forever for our splice_locked while we consider the exchange done and eventually promote. Our next commitment_signed is not a batch, and the peer force-closes with "Got a single commitment_signed message when expecting a batch". A staging client channel died this way on 2026-08-11 (MDK-1423): the LSP restarted between tx_signatures and the completed splice_locked exchange, and an MDK 0.18.0 wallet, whose persisted state apparently predated the LSP's tx_signatures, force-closed on the first payment forwarded after reconnect. Retransmit splice_locked explicitly when the peer's next_funding names a splice we have locked, ordered after the retransmitted tx_signatures so the peer finishes its signing session before it processes the lock. This is deliberately scoped: a peer whose signing session is complete can already infer the lock from my_current_funding_locked (covered by test_splice_reestablish), and retransmitting unconditionally would send redundant messages on every reconnect. Upstream fixed the same bug on main in 5434015b12b7 ("Retransmit splice_locked for 0-conf channels missing tx_signatures"), unreleased as of 0.3.0-beta1, whose retransmission only covers locks that were absent from our reestablish TLV. This is a minimal backport of its semantics for the lsp-0.2.5 branch, keyed off the peer's next_funding rather than our tx_signatures retransmission so it also fires when we cannot retransmit signatures but the peer can still complete the splice from on-chain confirmations. It interops with already deployed 0.2.0-era clients, which handle an explicit splice_locked fine but have no recovery path if it never arrives. --- lightning/src/ln/channel.rs | 25 ++++ lightning/src/ln/splicing_tests.rs | 185 +++++++++++++++++++++++++++++ 2 files changed, 210 insertions(+) diff --git a/lightning/src/ln/channel.rs b/lightning/src/ln/channel.rs index abe991ce88a..c58f6ccf322 100644 --- a/lightning/src/ln/channel.rs +++ b/lightning/src/ln/channel.rs @@ -1165,6 +1165,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, } @@ -10075,6 +10076,7 @@ where shutdown_msg, announcement_sigs, tx_signatures, tx_abort: None, + splice_locked: None, inferred_splice_locked: None, }); } @@ -10088,6 +10090,7 @@ where shutdown_msg, announcement_sigs, tx_signatures, tx_abort, + splice_locked: None, inferred_splice_locked: None, }); } @@ -10158,6 +10161,25 @@ where }) }); + // Their `next_funding` naming our locked splice txid means their signing session is + // still incomplete, so they cannot apply the `my_current_funding_locked` from our + // `channel_reestablish` (it only matches negotiated funding and is processed once). + // Retransmit `splice_locked` explicitly, ordered after the retransmitted + // `tx_signatures`, or they never learn of our lock and will force-close on our + // next non-batch `commitment_signed`. + let splice_locked = msg.next_funding.as_ref().and_then(|next_funding| { + self.pending_splice.as_ref().and_then(|pending_splice| { + pending_splice + .sent_funding_txid + .filter(|sent_txid| *sent_txid == next_funding.txid) + .filter(|sent_txid| Some(*sent_txid) != pending_splice.received_funding_txid) + .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 { log_debug!(logger, "Reconnected channel {} with only lost outbound RAA", &self.context.channel_id()); @@ -10175,6 +10197,7 @@ where commitment_order: self.context.resend_order.clone(), tx_signatures, tx_abort, + splice_locked: splice_locked.clone(), inferred_splice_locked, }) } else if msg.next_local_commitment_number == next_counterparty_commitment_number - 1 { @@ -10200,6 +10223,7 @@ where commitment_order: self.context.resend_order.clone(), tx_signatures: None, tx_abort, + splice_locked: splice_locked.clone(), inferred_splice_locked, }) } else { @@ -10227,6 +10251,7 @@ where commitment_order: self.context.resend_order.clone(), tx_signatures: None, tx_abort, + splice_locked: splice_locked.clone(), inferred_splice_locked, }) } diff --git a/lightning/src/ln/splicing_tests.rs b/lightning/src/ln/splicing_tests.rs index e60ecf7dddc..87bc8ee4371 100644 --- a/lightning/src/ln/splicing_tests.rs +++ b/lightning/src/ln/splicing_tests.rs @@ -2648,3 +2648,188 @@ fn test_splice_minimum_depth_not_applied_for_self_initiated() { // Verify the channel is usable. send_payment(&nodes[0], &[&nodes[1]], 100_000); } + +#[test] +fn test_splice_locked_conveyed_after_reestablish_signature_retransmission() { + // A splice acceptor loses the initiator's `tx_signatures` (e.g. it crashed before + // processing them), so at reestablish time its splice funding is still in + // `funding_negotiation` rather than `negotiated_candidates`. The initiator's + // `channel_reestablish` carries `my_current_funding_locked` for the splice, but the + // acceptor cannot infer a `splice_locked` from it yet and silently drops it. After + // the signature retransmission completes the splice, the acceptor locks and sends + // `splice_locked`, the initiator promotes — but the acceptor is still waiting for + // the initiator's `splice_locked`, which must be (re)transmitted after reestablish + // or the next single `commitment_signed` force-closes the channel with "Got a + // single commitment_signed message when expecting a batch". + 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 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[0], channel_id).get_funding_txo(); + let prev_funding_script = get_monitor!(nodes[0], channel_id).get_funding_script(); + + let contribution = SpliceContribution::SpliceOut { + outputs: vec![TxOut { + value: Amount::from_sat(initial_channel_value_sat / 4), + script_pubkey: nodes[0].wallet_source.get_change_script().unwrap(), + }], + }; + let initial_commit_sig_for_acceptor = + negotiate_splice_tx(&nodes[0], &nodes[1], channel_id, contribution); + + // The acceptor sends its `commitment_signed` and `tx_signatures`; the initiator + // signs and broadcasts, but its `tx_signatures` never reaches the acceptor. + nodes[1].node.handle_commitment_signed(node_id_0, &initial_commit_sig_for_acceptor); + let mut msg_events = nodes[1].node.get_and_clear_pending_msg_events(); + assert_eq!(msg_events.len(), 2, "{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!(); + } + if let MessageSendEvent::SendTxSignatures { ref msg, .. } = &msg_events[1] { + nodes[0].node.handle_tx_signatures(node_id_1, msg); + } else { + panic!(); + } + + let event = get_event!(nodes[0], Event::FundingTransactionReadyForSigning); + if let Event::FundingTransactionReadyForSigning { + channel_id, + counterparty_node_id, + unsigned_transaction, + .. + } = event + { + let partially_signed_tx = nodes[0].wallet_source.sign_tx(unsigned_transaction).unwrap(); + nodes[0] + .node + .funding_transaction_signed(&channel_id, &counterparty_node_id, partially_signed_tx) + .unwrap(); + } + let msg_events = nodes[0].node.get_and_clear_pending_msg_events(); + assert_eq!(msg_events.len(), 1, "{msg_events:?}"); + if let MessageSendEvent::SendTxSignatures { .. } = &msg_events[0] { + // Lost: never delivered to the acceptor. + } else { + panic!(); + } + check_added_monitors(&nodes[0], 1); + expect_splice_pending_event(&nodes[0], &node_id_1); + + let splice_tx = { + let mut txn = nodes[0].tx_broadcaster.txn_broadcast(); + assert_eq!(txn.len(), 1); + txn.remove(0) + }; + + // The peers disconnect; the splice confirms for both while disconnected. The + // initiator locks it (the queued `splice_locked` has no peer to go to); the + // acceptor cannot lock as its signing session is incomplete. + nodes[0].node.peer_disconnected(node_id_1); + nodes[1].node.peer_disconnected(node_id_0); + + mine_transaction(&nodes[0], &splice_tx); + connect_blocks(&nodes[0], ANTI_REORG_DELAY - 1); + // The initiator marks the splice as locked on its side, but the `splice_locked` it + // generates is dropped on the floor since the peer is disconnected. The acceptor's + // chain view lags and hasn't seen the confirmation yet. + assert!(nodes[0].node.get_and_clear_pending_msg_events().is_empty()); + + // Reestablish. The initiator's `my_current_funding_locked` cannot be applied by the + // acceptor yet (its splice funding is not negotiated), so the initiator must convey + // its `splice_locked` again once the acceptor is able to process it. + 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]); + assert_eq!(reestablish_0.len(), 1); + assert_eq!(reestablish_1.len(), 1); + + nodes[1].node.handle_channel_reestablish(node_id_0, &reestablish_0[0]); + let msgs_1 = nodes[1].node.get_and_clear_pending_msg_events(); + nodes[0].node.handle_channel_reestablish(node_id_1, &reestablish_1[0]); + let msgs_0 = nodes[0].node.get_and_clear_pending_msg_events(); + // The acceptor could not use `my_current_funding_locked`: its splice funding is + // still pending signatures, so it only retransmits messages for the original + // funding. + assert_eq!(msgs_1.len(), 2, "{msgs_1:?}"); + matches!(msgs_1[0], MessageSendEvent::SendAnnouncementSignatures { .. }) + .then_some(()) + .unwrap_or_else(|| panic!("{msgs_1:?}")); + matches!(msgs_1[1], MessageSendEvent::SendChannelUpdate { .. }) + .then_some(()) + .unwrap_or_else(|| panic!("{msgs_1:?}")); + + // The initiator retransmits its `tx_signatures`, and since it cannot know whether + // its `splice_locked` was ever delivered, it must retransmit that too, after the + // signatures. + let mut tx_signatures = None; + let mut splice_locked = None; + for msg in &msgs_0 { + match msg { + MessageSendEvent::SendTxSignatures { msg, .. } => { + assert!(splice_locked.is_none(), "splice_locked must follow tx_signatures"); + tx_signatures = Some(msg.clone()); + }, + MessageSendEvent::SendSpliceLocked { msg, .. } => splice_locked = Some(msg.clone()), + MessageSendEvent::SendAnnouncementSignatures { .. } => {}, + MessageSendEvent::SendChannelUpdate { .. } => {}, + _ => panic!("unexpected message {msg:?}"), + } + } + nodes[1].node.handle_tx_signatures(node_id_0, &tx_signatures.expect("lost tx_signatures")); + check_added_monitors(&nodes[1], 1); + expect_splice_pending_event(&nodes[1], &node_id_0); + { + let txn = nodes[1].tx_broadcaster.txn_broadcast(); + assert_eq!(txn.len(), 1); + assert_eq!(txn[0], splice_tx); + } + + let splice_locked = splice_locked.expect("initiator must retransmit splice_locked"); + nodes[1].node.handle_splice_locked(node_id_0, &splice_locked); + + // Once the acceptor's chain catches up it sends its own `splice_locked` and, having + // now received the initiator's, the splice promotes on both sides. + mine_transaction(&nodes[1], &splice_tx); + connect_blocks(&nodes[1], ANTI_REORG_DELAY - 1); + let mut msgs_1 = nodes[1].node.get_and_clear_pending_msg_events(); + check_added_monitors(&nodes[1], 1); + expect_channel_ready_event(&nodes[1], &node_id_0); + let splice_locked_for_initiator = loop { + match msgs_1.remove(0) { + MessageSendEvent::SendSpliceLocked { msg, .. } => break msg, + MessageSendEvent::SendAnnouncementSignatures { .. } => {}, + MessageSendEvent::SendChannelUpdate { .. } => {}, + msg => panic!("unexpected message {msg:?}"), + } + }; + nodes[0].node.handle_splice_locked(node_id_1, &splice_locked_for_initiator); + check_added_monitors(&nodes[0], 1); + expect_channel_ready_event(&nodes[0], &node_id_1); + + // Drain the remaining gossip messages on both sides. + let _ = nodes[0].node.get_and_clear_pending_msg_events(); + let _ = nodes[1].node.get_and_clear_pending_msg_events(); + + // Remove the previous funding info the chain source was watching to avoid failing + // the end-of-test sanity checks. + 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); + + // The channel must remain usable: a single (non-batch) `commitment_signed` must be + // acceptable to both sides. + send_payment(&nodes[0], &[&nodes[1]], 1_000_000); +}