ln: no longer support claims with missing counterparty_node_id
What changed, and why it matters
This commit removes a fallback code path in LDK that previously tried to replay old Lightning payment claims even when the software didn't know which peer (counterparty) the claim was for. That missing information could cause crashes or unreliable behavior. Now, if the required peer ID is absent, LDK refuses to load the channel state and logs an error, forcing users to resolve those old forwards before upgrading. It is a hardening/cleanup change rather than a fix for an active exploit.
Treat as a defensive hardening change. Users upgrading from LDK 0.0.123 or earlier should ensure all forwarded HTLCs are resolved before upgrading, as the commit now errors on startup instead of attempting risky replay. No immediate patch or CVE action is required unless a release note or advisory is later published.
Security signals we found
Removal of unsafe fallback path that could panic at runtime when counterparty_node_id is missing
Load-time failure (fail_read) introduced instead of best-effort replay with crash risk
Deprecation of legacy data from pre-0.0.124 forwards
Code comment explicitly states 'we no longer support claiming an HTLC where we don't have the counterparty_node_id'
No CVE, advisory, or researcher attribution present in commit materials
Evidence from the diff
The patch refactors ChannelManager::read startup replay logic for outbound HTLC preimages. It deletes the old branch that attempted to continue when prev_hop.counterparty_node_id is None, replacing it with a simple fail_read = true guard. It also removes the local import of chain::channelmonitor::Balance because the balance-inspection fallback logic is gone. The change deprecates support for claims forwarded before LDK 0.0.124 and makes missing counterparty_node_id a load-time failure by 0.3.
Changed components
lightning/src/ln/channelmanager.rsChannelManager startup/read pathOutbound HTLC preimage replay logicInspect captured patch +57 / −101
diff --git a/lightning/src/ln/channelmanager.rs b/lightning/src/ln/channelmanager.rs
index 260a9d0..3600b97 100644
--- a/lightning/src/ln/channelmanager.rs
+++ b/lightning/src/ln/channelmanager.rs
@@ -43,7 +43,7 @@ use crate::chain::chaininterface::{
TransactionType,
};
use crate::chain::channelmonitor::{
- Balance, ChannelMonitor, ChannelMonitorUpdate, ChannelMonitorUpdateStep, MonitorEvent,
+ ChannelMonitor, ChannelMonitorUpdate, ChannelMonitorUpdateStep, MonitorEvent,
WithChannelMonitor, ANTI_REORG_DELAY, CLTV_CLAIM_BUFFER, HTLC_FAIL_BACK_BUFFER,
LATENCY_GRACE_PERIOD_BLOCKS, MAX_BLOCKS_FOR_CONF,
};
@@ -19507,112 +19507,68 @@ impl<
// preimages from it which may be needed in upstream channels for forwarded
// payments.
let mut fail_read = false;
- let outbound_claimed_htlcs_iter = monitor.get_all_current_outbound_htlcs()
+ let outbound_claimed_htlcs_iter = monitor
+ .get_all_current_outbound_htlcs()
.into_iter()
.filter_map(|(htlc_source, (htlc, preimage_opt))| {
- if let HTLCSource::PreviousHopData(prev_hop) = &htlc_source {
- if let Some(payment_preimage) = preimage_opt {
- let inbound_edge_monitor = args.channel_monitors.get(&prev_hop.channel_id);
- // Note that for channels which have gone to chain,
- // `get_all_current_outbound_htlcs` is never pruned and always returns
- // a constant set until the monitor is removed/archived. Thus, we
- // want to skip replaying claims that have definitely been resolved
- // on-chain.
-
- // If the inbound monitor is not present, we assume it was fully
- // resolved and properly archived, implying this payment had plenty
- // of time to get claimed and we can safely skip any further
- // attempts to claim it (they wouldn't succeed anyway as we don't
- // have a monitor against which to do so).
- let inbound_edge_monitor = if let Some(monitor) = inbound_edge_monitor {
- monitor
- } else {
- return None;
- };
- // Second, if the inbound edge of the payment's monitor has been
- // fully claimed we've had at least `ANTI_REORG_DELAY` blocks to
- // get any PaymentForwarded event(s) to the user and assume that
- // there's no need to try to replay the claim just for that.
- let inbound_edge_balances = inbound_edge_monitor.get_claimable_balances();
- if inbound_edge_balances.is_empty() {
- return None;
- }
-
- if prev_hop.counterparty_node_id.is_none() {
- // We no longer support claiming an HTLC where we don't have
- // the counterparty_node_id available if the claim has to go to
- // a closed channel. Its possible we can get away with it if
- // the channel is not yet closed, but its by no means a
- // guarantee.
-
- // Thus, in this case we are a bit more aggressive with our
- // pruning - if we have no use for the claim (because the
- // inbound edge of the payment's monitor has already claimed
- // the HTLC) we skip trying to replay the claim.
- let htlc_payment_hash: PaymentHash = payment_preimage.into();
- let logger = WithChannelMonitor::from(
- &args.logger,
- monitor,
- Some(htlc_payment_hash),
- );
- let balance_could_incl_htlc = |bal| match bal {
- &Balance::ClaimableOnChannelClose { .. } => {
- // The channel is still open, assume we can still
- // claim against it
- true
- },
- &Balance::MaybePreimageClaimableHTLC { payment_hash, .. } => {
- payment_hash == htlc_payment_hash
- },
- _ => false,
- };
- let htlc_may_be_in_balances =
- inbound_edge_balances.iter().any(balance_could_incl_htlc);
- if !htlc_may_be_in_balances {
- return None;
- }
+ let payment_preimage = preimage_opt?;
+ let prev_htlcs = match &htlc_source {
+ HTLCSource::PreviousHopData(prev_hop) => vec![prev_hop],
+ // If it was an outbound payment, we've handled it above - if a preimage
+ // came in and we persisted the `ChannelManager` we either handled it
+ // and are good to go or the channel force-closed - we don't have to
+ // handle the channel still live case here.
+ _ => vec![],
+ };
+ let prev_htlcs_count = prev_htlcs.len();
+ if prev_htlcs_count == 0 {
+ return None;
+ }
- // First check if we're absolutely going to fail - if we need
- // to replay this claim to get the preimage into the inbound
- // edge monitor but the channel is closed (and thus we'll
- // immediately panic if we call claim_funds_from_hop).
- if short_to_chan_info.get(&prev_hop.prev_outbound_scid_alias).is_none() {
- log_error!(logger,
- "We need to replay the HTLC claim for payment_hash {} (preimage {}) but cannot do so as the HTLC was forwarded prior to LDK 0.0.124.\
- All HTLCs that were forwarded by LDK 0.0.123 and prior must be resolved prior to upgrading to LDK 0.1",
- htlc_payment_hash,
- payment_preimage,
- );
- fail_read = true;
- }
+ for prev_hop in prev_htlcs {
+ // Note that for channels which have gone to chain,
+ // `get_all_current_outbound_htlcs` is never pruned and always returns
+ // a constant set until the monitor is removed/archived. Thus, we want
+ // to skip replaying claims that have definitely been resolved on-chain.
+
+ // If the inbound monitor is not present, we assume it was fully
+ // resolved and properly archived, implying this payment had plenty of
+ // time to get claimed and we can safely skip any further attempts to
+ // claim it (they wouldn't succeed anyway as we don't have a monitor
+ // against which to do so).
+ let inbound_edge_monitor =
+ match args.channel_monitors.get(&prev_hop.channel_id) {
+ Some(monitor) => monitor,
+ None => continue,
+ };
- // At this point we're confident we need the claim, but the
- // inbound edge channel is still live. As long as this remains
- // the case, we can conceivably proceed, but we run some risk
- // of panicking at runtime. The user ideally should have read
- // the release notes and we wouldn't be here, but we go ahead
- // and let things run in the hope that it'll all just work out.
- log_error!(logger,
- "We need to replay the HTLC claim for payment_hash {} (preimage {}) but don't have all the required information to do so reliably.\
- As long as the channel for the inbound edge of the forward remains open, this may work okay, but we may panic at runtime!\
- All HTLCs that were forwarded by LDK 0.0.123 and prior must be resolved prior to upgrading to LDK 0.1\
- Continuing anyway, though panics may occur!",
- htlc_payment_hash,
- payment_preimage,
- );
- }
+ if inbound_edge_monitor.get_claimable_balances().is_empty() {
+ continue;
+ }
- Some((htlc_source, payment_preimage, htlc.amount_msat,
- is_channel_closed, monitor.get_counterparty_node_id(),
- monitor.get_funding_txo(), monitor.channel_id(), user_channel_id_opt))
- } else { None }
- } else {
- // If it was an outbound payment, we've handled it above - if a preimage
- // came in and we persisted the `ChannelManager` we either handled it and
- // are good to go or the channel force-closed - we don't have to handle the
- // channel still live case here.
- None
+ // We no longer support claiming an HTLC where we don't have the
+ // counterparty_node_id. This field has been populated since 0.0.124,
+ // so we expect it to be present for in flight claims in 0.3+.
+ if prev_hop.counterparty_node_id.is_none() {
+ fail_read = true;
+ return None;
+ }
+ return Some((
+ // When we have multiple prev_htlcs we know that they are all from
+ // a single HTLCSource (see match above) which contains all previous
+ // hops, so we can exit on the first claimable prev_hop because this
+ // will result in all prev_hops being claimed.
+ htlc_source,
+ payment_preimage,
+ htlc.amount_msat,
+ is_channel_closed,
+ monitor.get_counterparty_node_id(),
+ monitor.get_funding_txo(),
+ monitor.channel_id(),
+ user_channel_id_opt,
+ ));
}
+ None
});
for tuple in outbound_claimed_htlcs_iter {
pending_claims_to_replay.push(tuple);
Why this scored 41/100
Community notes
Notes can correct, qualify, or add evidence to the AI analysis. Every note shown here has been validated by a human moderator.
The AI analysis stands alone for now. Submit a note if you can add evidence or important context.