Send held_htlc_available with counterparty reply path
What changed, and why it matters
This commit adds a feature for asynchronous Lightning payments where an often-offline sender can ask their always-online channel counterparty to hold a payment. The change ensures that when the counterparty confirms a channel state update (via revoke_and_ack), the sender extracts reply paths provided by the counterparty and uses them to send 'held_htlc_available' onion messages. The reply paths ensure the recipient's release response returns to the sender's online counterparty. The commit also includes a defensive check to ignore reply paths for HTLCs that were not actually configured as async payments, which limits a potential privacy leak where a counterparty could otherwise learn which payments are async.
Review as normal feature work with a security-relevant validation. Verify that the static_invoice/hold_htlc guard is sufficient and that no other code paths accept release_htlc_message_paths without equivalent checks. Consider whether a malicious counterparty can still infer async-payment status through timing, ordering, or error responses. No immediate patch or incident response is indicated by the diff alone.
Security signals we found
New defensive validation: counterparty-provided release_htlc_message_paths are only accepted for HTLCs that have a static invoice and hold_htlc configured, mitigating a potential information-disclosure side channel.
Async payment reply paths are now routed through the always-online channel counterparty rather than the offline sender, which is a protocol correctness change for BOLT 12 async payments.
debug_assert! guards the assumption that enqueue_held_htlc_available only fails for non-async senders; this is an internal invariant, not runtime error handling.
Evidence from the diff
The patch modifies Channel::revoke_and_ack to return an additional Vec<(StaticInvoice, BlindedMessagePath)> collected from msg.release_htlc_message_paths. It only pairs a path with an HTLC when htlc.source.static_invoice() returns Some and htlc.hold_htlc is set; otherwise it logs and skips, preventing a counterparty from tagging arbitrary HTLCs as async. ChannelManager then enqueues HeldHtlcAvailable onion messages via self.flow.enqueue_held_htlc_available with HeldHtlcReplyPath::ToCounterparty { path: reply_path }. A new HTLCSource::static_invoice helper extracts StaticInvoice from OutboundRoute sources using PaidBolt12Invoice::StaticInvoice.
Changed components
lightning/src/ln/channel.rslightning/src/ln/channelmanager.rsOutboundRoute / HTLCSourceHeldHtlcAvailable / ReleaseHeldHtlc onion message flowStaticInvoice handlingInspect captured patch +55 / −6
diff --git a/lightning/src/ln/channel.rs b/lightning/src/ln/channel.rs
index b3cac74..efdb516 100644
--- a/lightning/src/ln/channel.rs
+++ b/lightning/src/ln/channel.rs
@@ -70,6 +70,7 @@ use crate::ln::onion_utils::{
use crate::ln::script::{self, ShutdownScript};
use crate::ln::types::ChannelId;
use crate::ln::LN_MAX_MSG_LEN;
+use crate::offers::static_invoice::StaticInvoice;
use crate::routing::gossip::NodeId;
use crate::sign::ecdsa::EcdsaChannelSigner;
use crate::sign::tx_builder::{HTLCAmountDirection, NextCommitmentStats, SpecTxBuilder, TxBuilder};
@@ -8184,10 +8185,25 @@ where
/// waiting on this revoke_and_ack. The generation of this new commitment_signed may also fail,
/// generating an appropriate error *after* the channel state has been updated based on the
/// revoke_and_ack message.
+ ///
+ /// The static invoices will be used by us as an async sender to enqueue [`HeldHtlcAvailable`]
+ /// onion messages for the often-offline recipient, and the blinded reply paths the invoices are
+ /// paired with were created by our channel counterparty and will be used as reply paths for
+ /// corresponding [`ReleaseHeldHtlc`] messages.
+ ///
+ /// [`HeldHtlcAvailable`]: crate::onion_message::async_payments::HeldHtlcAvailable
+ /// [`ReleaseHeldHtlc`]: crate::onion_message::async_payments::ReleaseHeldHtlc
pub fn revoke_and_ack<F: Deref, L: Deref>(
&mut self, msg: &msgs::RevokeAndACK, fee_estimator: &LowerBoundedFeeEstimator<F>,
logger: &L, hold_mon_update: bool,
- ) -> Result<(Vec<(HTLCSource, PaymentHash)>, Option<ChannelMonitorUpdate>), ChannelError>
+ ) -> Result<
+ (
+ Vec<(HTLCSource, PaymentHash)>,
+ Vec<(StaticInvoice, BlindedMessagePath)>,
+ Option<ChannelMonitorUpdate>,
+ ),
+ ChannelError,
+ >
where
F::Target: FeeEstimator,
L::Target: Logger,
@@ -8302,6 +8318,7 @@ where
let mut finalized_claimed_htlcs = Vec::new();
let mut update_fail_htlcs = Vec::new();
let mut update_fail_malformed_htlcs = Vec::new();
+ let mut static_invoices = Vec::new();
let mut require_commitment = false;
let mut value_to_self_msat_diff: i64 = 0;
@@ -8417,6 +8434,24 @@ where
}
}
for htlc in pending_outbound_htlcs.iter_mut() {
+ for (htlc_id, blinded_path) in &msg.release_htlc_message_paths {
+ if htlc.htlc_id != *htlc_id {
+ continue;
+ }
+ let static_invoice = match htlc.source.static_invoice() {
+ Some(inv) if htlc.hold_htlc.is_some() => inv,
+ _ => {
+ // We should only be using our counterparty's release_htlc_message_path if we
+ // originally configured the HTLC to be held with them until the recipient comes
+ // online. Otherwise, our counterparty could include paths for all of our HTLCs and
+ // use the responses sent to their paths to determine which of our HTLCs are async
+ // payments.
+ log_trace!(logger, "Counterparty included release_htlc_message_path for non-async payment HTLC {}", htlc_id);
+ continue;
+ },
+ };
+ static_invoices.push((static_invoice, blinded_path.clone()));
+ }
if let OutboundHTLCState::LocalAnnounced(_) = htlc.state {
log_trace!(
logger,
@@ -8484,9 +8519,9 @@ where
self.context
.blocked_monitor_updates
.push(PendingChannelMonitorUpdate { update: monitor_update });
- return Ok(($htlcs_to_fail, None));
+ return Ok(($htlcs_to_fail, static_invoices, None));
} else {
- return Ok(($htlcs_to_fail, Some(monitor_update)));
+ return Ok(($htlcs_to_fail, static_invoices, Some(monitor_update)));
}
};
}
diff --git a/lightning/src/ln/channelmanager.rs b/lightning/src/ln/channelmanager.rs
index 523de99..c1d38a0 100644
--- a/lightning/src/ln/channelmanager.rs
+++ b/lightning/src/ln/channelmanager.rs
@@ -873,6 +873,16 @@ impl HTLCSource {
_ => None,
}
}
+
+ pub(crate) fn static_invoice(&self) -> Option<StaticInvoice> {
+ match self {
+ Self::OutboundRoute {
+ bolt12_invoice: Some(PaidBolt12Invoice::StaticInvoice(inv)),
+ ..
+ } => Some(inv.clone()),
+ _ => None,
+ }
+ }
}
/// This enum is used to specify which error data to send to peers when failing back an HTLC
@@ -11011,7 +11021,7 @@ This indicates a bug inside LDK. Please report this error at https://github.com/
#[rustfmt::skip]
fn internal_revoke_and_ack(&self, counterparty_node_id: &PublicKey, msg: &msgs::RevokeAndACK) -> Result<(), MsgHandleErrInternal> {
- let htlcs_to_fail = {
+ let (htlcs_to_fail, static_invoices) = {
let per_peer_state = self.per_peer_state.read().unwrap();
let mut peer_state_lock = per_peer_state.get(counterparty_node_id)
.ok_or_else(|| {
@@ -11027,7 +11037,7 @@ This indicates a bug inside LDK. Please report this error at https://github.com/
let mon_update_blocked = self.raa_monitor_updates_held(
&peer_state.actions_blocking_raa_monitor_updates, msg.channel_id,
*counterparty_node_id);
- let (htlcs_to_fail, monitor_update_opt) = try_channel_entry!(self, peer_state,
+ let (htlcs_to_fail, static_invoices, monitor_update_opt) = try_channel_entry!(self, peer_state,
chan.revoke_and_ack(&msg, &self.fee_estimator, &&logger, mon_update_blocked), chan_entry);
if let Some(monitor_update) = monitor_update_opt {
let funding_txo = funding_txo_opt
@@ -11035,7 +11045,7 @@ This indicates a bug inside LDK. Please report this error at https://github.com/
handle_new_monitor_update!(self, funding_txo, monitor_update,
peer_state_lock, peer_state, per_peer_state, chan);
}
- htlcs_to_fail
+ (htlcs_to_fail, static_invoices)
} else {
return try_channel_entry!(self, peer_state, Err(ChannelError::close(
"Got a revoke_and_ack message for an unfunded channel!".into())), chan_entry);
@@ -11045,6 +11055,10 @@ This indicates a bug inside LDK. Please report this error at https://github.com/
}
};
self.fail_holding_cell_htlcs(htlcs_to_fail, msg.channel_id, counterparty_node_id);
+ for (static_invoice, reply_path) in static_invoices {
+ let res = self.flow.enqueue_held_htlc_available(&static_invoice, HeldHtlcReplyPath::ToCounterparty { path: reply_path });
+ debug_assert!(res.is_ok(), "enqueue_held_htlc_available can only fail for non-async senders");
+ }
Ok(())
}
Why this scored 26/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.