Skip to content

Commit fb67c37

Browse files
authored
Merge pull request #4706 from TheBlueMatt/2026-06-0.2-backports-2
[0.2] Another round of backports + initial release notes
2 parents dccb334 + 0c07f97 commit fb67c37

15 files changed

Lines changed: 649 additions & 36 deletions

File tree

CHANGELOG.md

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,89 @@
1+
# 0.2.3 - Jun 18, 2026 - "Through the Loupe"
2+
3+
## API Updates
4+
* `DefaultMessageRouter` will now always generate blinded message paths that
5+
provide no privacy (where our node is the introduction node) for nodes with
6+
public channels. This works around an issue which will appear for any nodes
7+
with LND peers that enable onion messaging - such peers will refuse to
8+
forward BOLT 12 messages from unknown third parties, which most BOLT 12
9+
payers rely on today (#4647).
10+
* Explicit `amount_msats` of 0 is rejected in BOLT 12 `Offer`s; `OfferBuilder`
11+
now maps 0-amounts to an amount of `None` (#4324).
12+
13+
## Bug Fixes
14+
* `Features::supports_zero_conf` no longer clears the `ZeroConf` features and
15+
`Features::requires_zero_conf` now correctly reports required, rather than
16+
supported, status (#4517).
17+
* If an MPP payment is claimed but `ChannelMonitorUpdate`s for some parts are
18+
still being completed asynchronously, further channel updates (e.g.
19+
forwarding another payment) are pending and the node restarts, the channel
20+
could have become stuck (#4520).
21+
* The presence of unconfirmed transactions actually no longer causes
22+
`ElectrumSyncClient` to spuriously fail to sync (#4590).
23+
* LSPS1, LSPS2, and LSPS5 persistence will no longer get stuck and refuse to
24+
persist again after a single failure from the KVStore (#4597, #4282).
25+
* Dropping the future returned by
26+
`OutputSweeper::regenerate_and_broadcast_spend_if_necessary` no longer
27+
results in future calls to the same method being spuriously ignored (#4598).
28+
* Used async-receive offers are no longer refreshed on every timer tick once
29+
their refresh time is reached (#4672).
30+
* `FilesystemStore::list_all_keys` will no longer fail if there are stale
31+
intermediate files lying around from a previous unclean shutdown (#4618).
32+
* When forwarding an HTLC while in a blinded path with proportional fees over
33+
200%, LDK will no longer spuriously allow a forward that pays us 1 msat too
34+
little in fees (#4697).
35+
* Fixed a rare case where a channel could get stuck on reconnect when using
36+
both async `ChannelMonitorUpdate` persistence and async signing (#4684).
37+
* If we had exactly zero balance in a zero-fee-commitment channel, the
38+
counterparty was able to splice all of their balance out, violating the
39+
reserve requirements they'd otherwise be forced to keep (#4580).
40+
* Providing an `Event::HTLCIntercepted` to the `LSPS2ServiceHandler` twice no
41+
longer results in spuriously opening a channel early (#4656).
42+
* `Event::PaymentSent::fee_paid_msat` is no longer `None` in cases where
43+
`ChannelManager::abandon_payment` was called before the payment ultimately
44+
completes anyway (#4651).
45+
* `AnchorDescriptor::previous_utxo` now provides the correct `script_pubkey`
46+
for non-zero-commitment-fee anchor channels (#4669).
47+
* Syncing a `ChainMonitor` using the `Confirm` trait will no longer write some
48+
full `ChannelMonitor`s to disk several times per block (#4544).
49+
* `OMDomainResolver` now correctly accounts for failed queries when rate
50+
limiting, ensuring we continue to respond to queries after failures (#4591).
51+
* Calling `ChannelManager::send_payment_with_route` without a `route_params`
52+
and with an invalid `Route` will no longer panic (#4707).
53+
* `LSPS2ServiceHandler::channel_open_failed` now correctly fails intercepted
54+
HTLCs rather than allowing them to fail just before expiry (#4677).
55+
* `StaticInvoice::is_offer_expired` was corrected to check offer, rather than
56+
static invoice, expiry (#4594).
57+
* `lightning-custom-message`'s handling of `peer_connected` events now ensures
58+
that sub-handlers will see a `peer_disconnected` event if a different
59+
sub-handler refused the connection by `Err`ing `peer_connected` (#4595).
60+
* Replay protection for LSPS5 signatures now detects replays which are only
61+
different in the encoded signature's case (#4701).
62+
* When `lightning-liquidity` is configured in the background processor, there
63+
is no longer a stream of `Persisting LiquidityManager...` log spam (#4246).
64+
* Incomplete MPP keysend payments will no longer see their HTLCs held until
65+
expiry (#4558).
66+
* `InvoiceRequestBuilder` will no longer accept a `quantity` of `0` for a
67+
BOLT 12 `Offer`, allowing any quantity up to a bound (#4667).
68+
* `lightning-custom-message` handlers that return `Ok(None)` when asked to
69+
deserialize a message in their defined range no longer cause panics (#4709).
70+
* Several spurious debug assertions were fixed (#4537, #4618, #4026)
71+
72+
## Security
73+
0.2.3 fixes several underestimates of the anchor reserves required to ensure we
74+
can reliably close channels and a sanitization issue.
75+
* When using the `anchor_channel_reserves` module to calculate reserves
76+
required to pay for fees when closing anchor channels, zero-fee-commitment
77+
channels were not considered. This could allow a counterparty to open many
78+
channels, leaving us unable to properly force-close (#4592).
79+
* The `anchor_channel_reserves` module overestimated the value of `Utxo`s in
80+
the wallet by ignoring the `TxIn` cost to spend them (#4670).
81+
* `PrintableString` did not properly sanitize unicode format characters,
82+
allowing an attacker to corrupt the rendering of logs or UI (#4593, #4605).
83+
84+
Thanks to Project Loupe for reporting most of the issues fixed in this release.
85+
86+
187
# 0.2.2 - Feb 6, 2025 - "An Async Splicing Production"
288

389
## API Updates

lightning-custom-message/src/lib.rs

Lines changed: 71 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -358,7 +358,12 @@ macro_rules! composite_custom_message_handler {
358358
match message_type {
359359
$(
360360
$pattern => match <$type>::read(&self.$field, message_type, buffer)? {
361-
None => unreachable!(),
361+
// A sub-handler returns `None` for a `message_type` it doesn't
362+
// recognize. The composite's pattern can be broader than the types
363+
// the sub-handler decodes (e.g. a range), and `message_type` is
364+
// peer-provided, so report the message as unknown rather than
365+
// treating this as unreachable and panicking.
366+
None => Ok(None),
362367
Some(message) => Ok(Some($message::$variant(message))),
363368
},
364369
)*
@@ -501,6 +506,71 @@ mod tests {
501506
}
502507
);
503508

509+
struct ReservedBlockHandler;
510+
impl CustomMessageReader for ReservedBlockHandler {
511+
type CustomMessage = Foo;
512+
fn read<R: LengthLimitedRead>(
513+
&self, message_type: u16, _b: &mut R,
514+
) -> Result<Option<Foo>, DecodeError> {
515+
// This build defines only the message at 32768; the rest of the block its
516+
// protocol reserved (32768..=32777) is for types future versions may add.
517+
// A not-yet-defined type is unknown to this build, so per the
518+
// `CustomMessageReader` contract it returns `Ok(None)` -- a newer peer can
519+
// send one and this older node will treat it as an unknown message.
520+
match message_type {
521+
32768 => Ok(Some(Foo)),
522+
_ => Ok(None),
523+
}
524+
}
525+
}
526+
impl CustomMessageHandler for ReservedBlockHandler {
527+
fn handle_custom_message(&self, _msg: Foo, _: PublicKey) -> Result<(), LightningError> {
528+
Ok(())
529+
}
530+
fn get_and_clear_pending_msg(&self) -> Vec<(PublicKey, Foo)> {
531+
vec![]
532+
}
533+
fn peer_disconnected(&self, _: PublicKey) {}
534+
fn peer_connected(&self, _: PublicKey, _: &Init, _: bool) -> Result<(), ()> {
535+
Ok(())
536+
}
537+
fn provided_node_features(&self) -> NodeFeatures {
538+
NodeFeatures::empty()
539+
}
540+
fn provided_init_features(&self, _: PublicKey) -> InitFeatures {
541+
InitFeatures::empty()
542+
}
543+
}
544+
545+
composite_custom_message_handler!(
546+
struct ReservedBlockComposite {
547+
proto: ReservedBlockHandler,
548+
}
549+
550+
enum ReservedBlockMessage {
551+
Proto(32768..=32777),
552+
}
553+
);
554+
555+
#[test]
556+
fn read_treats_a_reserved_in_range_type_as_unknown() {
557+
// A sub-handler may own a block of type ids (declared here as a range) yet only
558+
// decode the subset its build defines, returning `Ok(None)` for reserved or
559+
// not-yet-defined types in the block -- exactly what a node does on receiving a
560+
// newer peer's message. `read` must surface that as an unknown message, not
561+
// panic.
562+
let composite = ReservedBlockComposite { proto: ReservedBlockHandler };
563+
let mut buffer: &[u8] = &[];
564+
// The message this build defines decodes to its variant.
565+
assert!(matches!(
566+
composite.read(32768, &mut buffer),
567+
Ok(Some(ReservedBlockMessage::Proto(_)))
568+
));
569+
// A reserved type from the same block is reported unknown, not panicked
570+
// (pre-fix the matched arm hit `unreachable!()`).
571+
assert!(matches!(composite.read(32770, &mut buffer), Ok(None)));
572+
}
573+
504574
#[test]
505575
fn peer_connected_failure_does_not_leak_subhandler_state() {
506576
let composite = CompositeHandler {

lightning-liquidity/src/lsps2/payment_queue.rs

Lines changed: 25 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -26,21 +26,29 @@ impl PaymentQueue {
2626
PaymentQueue { payments: Vec::new() }
2727
}
2828

29+
fn payment_status(entry: &PaymentQueueEntry) -> (u64, usize) {
30+
let total_expected_outbound_amount_msat =
31+
entry.htlcs.iter().map(|htlc| htlc.expected_outbound_amount_msat).sum();
32+
(total_expected_outbound_amount_msat, entry.htlcs.len())
33+
}
34+
2935
pub(crate) fn add_htlc(&mut self, new_htlc: InterceptedHTLC) -> (u64, usize) {
36+
if let Some(entry) = self
37+
.payments
38+
.iter()
39+
.find(|entry| entry.htlcs.iter().any(|htlc| htlc.intercept_id == new_htlc.intercept_id))
40+
{
41+
debug_assert_eq!(entry.payment_hash, new_htlc.payment_hash);
42+
return Self::payment_status(entry);
43+
}
44+
3045
let payment =
3146
self.payments.iter_mut().find(|entry| entry.payment_hash == new_htlc.payment_hash);
3247
if let Some(entry) = payment {
3348
// HTLCs within a payment should have the same payment hash.
3449
debug_assert!(entry.htlcs.iter().all(|htlc| htlc.payment_hash == entry.payment_hash));
35-
// The given HTLC should not already be present.
36-
debug_assert!(entry
37-
.htlcs
38-
.iter()
39-
.all(|htlc| htlc.intercept_id != new_htlc.intercept_id));
4050
entry.htlcs.push(new_htlc);
41-
let total_expected_outbound_amount_msat =
42-
entry.htlcs.iter().map(|htlc| htlc.expected_outbound_amount_msat).sum();
43-
(total_expected_outbound_amount_msat, entry.htlcs.len())
51+
Self::payment_status(entry)
4452
} else {
4553
let expected_outbound_amount_msat = new_htlc.expected_outbound_amount_msat;
4654
let entry =
@@ -127,6 +135,15 @@ mod tests {
127135
(500_000_000, 2),
128136
);
129137

138+
assert_eq!(
139+
payment_queue.add_htlc(InterceptedHTLC {
140+
intercept_id: InterceptId([2; 32]),
141+
expected_outbound_amount_msat: 300_000_000,
142+
payment_hash: PaymentHash([100; 32]),
143+
}),
144+
(500_000_000, 2),
145+
);
146+
130147
let expected_entry = PaymentQueueEntry {
131148
payment_hash: PaymentHash([100; 32]),
132149
htlcs: vec![

lightning-liquidity/src/lsps2/service.rs

Lines changed: 79 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1257,6 +1257,8 @@ where
12571257
/// This removes the intercept SCID, any outbound channel state, and associated
12581258
/// channel‐ID mappings for the specified `user_channel_id`, but only while no payment
12591259
/// has been forwarded yet and no channel has been opened on-chain.
1260+
/// Any held HTLCs for the pending flow are failed backwards before the local state
1261+
/// is removed.
12601262
///
12611263
/// Returns an error if:
12621264
/// - there is no channel matching `user_channel_id`, or
@@ -1292,25 +1294,27 @@ where
12921294

12931295
let jit_channel = peer_state
12941296
.outbound_channels_by_intercept_scid
1295-
.get(&intercept_scid)
1297+
.get_mut(&intercept_scid)
12961298
.ok_or_else(|| APIError::APIMisuseError {
1297-
err: format!(
1298-
"Failed to map intercept_scid {} for user_channel_id {} to a channel.",
1299-
intercept_scid, user_channel_id,
1300-
),
1301-
})?;
1299+
err: format!(
1300+
"Failed to map intercept_scid {} for user_channel_id {} to a channel.",
1301+
intercept_scid, user_channel_id,
1302+
),
1303+
})?;
13021304

1303-
let is_pending = matches!(
1304-
jit_channel.state,
1305-
OutboundJITChannelState::PendingInitialPayment { .. }
1306-
| OutboundJITChannelState::PendingChannelOpen { .. }
1307-
);
1305+
let intercepted_htlcs = match &mut jit_channel.state {
1306+
OutboundJITChannelState::PendingInitialPayment { payment_queue }
1307+
| OutboundJITChannelState::PendingChannelOpen { payment_queue, .. } => payment_queue.clear(),
1308+
_ => {
1309+
return Err(APIError::APIMisuseError {
1310+
err: "Cannot abandon channel open after channel creation or payment forwarding"
1311+
.to_string(),
1312+
});
1313+
},
1314+
};
13081315

1309-
if !is_pending {
1310-
return Err(APIError::APIMisuseError {
1311-
err: "Cannot abandon channel open after channel creation or payment forwarding"
1312-
.to_string(),
1313-
});
1316+
for htlc in intercepted_htlcs {
1317+
let _ = self.channel_manager.get_cm().fail_intercepted_htlc(htlc.intercept_id);
13141318
}
13151319

13161320
peer_state.intercept_scid_by_user_channel_id.remove(&user_channel_id);
@@ -1784,14 +1788,22 @@ where
17841788
// TODO: We should eventually persist in parallel, however, when we do, we probably want to
17851789
// introduce some batching to upper-bound the number of requests inflight at any given
17861790
// time.
1787-
let mut did_persist = false;
17881791

17891792
if self.persistence_in_flight.fetch_add(1, Ordering::AcqRel) > 0 {
17901793
// If we're not the first event processor to get here, just return early, the increment
17911794
// we just did will be treated as "go around again" at the end.
1792-
return Ok(did_persist);
1795+
return Ok(false);
17931796
}
17941797

1798+
let res = self.do_persist().await;
1799+
debug_assert!(res.is_err() || self.persistence_in_flight.load(Ordering::Acquire) == 0);
1800+
self.persistence_in_flight.store(0, Ordering::Release);
1801+
res
1802+
}
1803+
1804+
async fn do_persist(&self) -> Result<bool, lightning::io::Error> {
1805+
let mut did_persist = false;
1806+
17951807
loop {
17961808
let mut need_remove = Vec::new();
17971809
let mut need_persist = Vec::new();
@@ -1853,6 +1865,7 @@ where
18531865
did_persist = true;
18541866
} else {
18551867
self.persist_peer_state(counterparty_node_id).await?;
1868+
did_persist = true;
18561869
}
18571870
}
18581871

@@ -2353,6 +2366,8 @@ mod tests {
23532366

23542367
use bitcoin::{absolute::LockTime, transaction::Version};
23552368
use core::str::FromStr;
2369+
use lightning::io::Cursor;
2370+
use lightning::util::ser::{Readable, Writeable};
23562371

23572372
const MAX_VALUE_MSAT: u64 = 21_000_000_0000_0000_000;
23582373

@@ -2760,6 +2775,52 @@ mod tests {
27602775
}
27612776
}
27622777

2778+
#[test]
2779+
fn replayed_intercepted_htlc_after_persist_is_idempotent() {
2780+
let payment_size_msat = Some(500_000_000);
2781+
let opening_fee_params = LSPS2OpeningFeeParams {
2782+
min_fee_msat: 10_000_000,
2783+
proportional: 10_000,
2784+
valid_until: LSPSDateTime::from_str("2035-05-20T08:30:45Z").unwrap(),
2785+
min_lifetime: 4032,
2786+
max_client_to_self_delay: 2016,
2787+
min_payment_size_msat: 10_000_000,
2788+
max_payment_size_msat: 1_000_000_000,
2789+
promise: "ignore".to_string(),
2790+
};
2791+
let intercept_scid = 42;
2792+
let user_channel_id = 43;
2793+
let htlc = InterceptedHTLC {
2794+
intercept_id: InterceptId([1; 32]),
2795+
expected_outbound_amount_msat: 500_000_000,
2796+
payment_hash: PaymentHash([2; 32]),
2797+
};
2798+
2799+
let mut jit_channel =
2800+
OutboundJITChannel::new(payment_size_msat, opening_fee_params, user_channel_id, false);
2801+
assert!(matches!(
2802+
jit_channel.htlc_intercepted(htlc).unwrap(),
2803+
Some(HTLCInterceptedAction::OpenChannel(_))
2804+
));
2805+
2806+
let mut peer_state = PeerState::new();
2807+
peer_state.intercept_scid_by_user_channel_id.insert(user_channel_id, intercept_scid);
2808+
peer_state.insert_outbound_channel(intercept_scid, jit_channel);
2809+
2810+
let encoded_peer_state = peer_state.encode();
2811+
let mut decoded_peer_state = PeerState::read(&mut Cursor::new(encoded_peer_state)).unwrap();
2812+
let decoded_jit_channel = decoded_peer_state
2813+
.outbound_channels_by_intercept_scid
2814+
.get_mut(&intercept_scid)
2815+
.unwrap();
2816+
2817+
assert!(decoded_jit_channel.htlc_intercepted(htlc).unwrap().is_none());
2818+
2819+
let ForwardPaymentAction(_, fee_payment) =
2820+
decoded_jit_channel.channel_ready(ChannelId([3; 32])).unwrap();
2821+
assert_eq!(fee_payment.htlcs, vec![htlc]);
2822+
}
2823+
27632824
#[test]
27642825
fn broadcast_not_allowed_after_non_paying_fee_payment_claimed() {
27652826
let min_fee_msat: u64 = 12345;

lightning-liquidity/src/lsps5/service.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -339,6 +339,7 @@ where
339339
did_persist = true;
340340
} else {
341341
self.persist_peer_state(client_id).await?;
342+
did_persist = true;
342343
}
343344
}
344345

0 commit comments

Comments
 (0)