Set UpdateAddHTLC::hold_htlc for offline payees
What changed, and why it matters
This commit finishes a feature that lets an often-offline Lightning payer ask the next node in the payment path to hold the payment (HTLC) until the offline recipient comes online and releases it. The change wires a previously unused flag through the payment-sending code so the 'hold_htlc' bit is actually set on outgoing HTLCs. It is a protocol feature implementation, not a fix for an active bug or exploit, but it touches payment logic and changes retry behavior for held HTLCs.
Review as part of normal feature merge. Verify that disabling retries (Retry::Attempts(0)) when hold_htlcs_at_next_hop is true cannot cause funds to become stuck if the next hop does not actually hold or release the HTLC, and confirm the fallback to enqueue_held_htlc_available is still safe when hold_htlc_channels() fails. No immediate security patch appears required.
Security signals we found
New protocol flag wired into outgoing HTLC messages
Retry disabled when HTLCs are intentionally held at the next hop
Payment path selection and fallback logic changed for static invoices
Feature tied to BOLTs PR 989 (offline/async payment support)
Evidence from the diff
The commit propagates a new boolean, hold_htlc_at_next_hop / hold_htlcs_at_next_hop, from ChannelManager down through OutboundPayments to SendAlongPathArgs and finally into channel::send_htlc’s UpdateAddHTLC::hold_htlc field. Previously the field was hard-coded to None. For static/BOLT12 invoices, if the sender is a private node and can hold HTLCs at the next hop, it now sends via send_payment_for_static_invoice_no_persist with hold_htlcs_at_next_hop=true and disables retries (Retry::Attempts(0)) because the next hop is expected to hold. Otherwise it falls back to the existing enqueue_held_htlc_available flow. The change is part of BOLTs PR 989 for often-offline senders.
Changed components
lightning/src/ln/channel.rslightning/src/ln/channelmanager.rslightning/src/ln/outbound_payment.rsInspect captured patch +54 / −25
diff --git a/lightning/src/ln/channel.rs b/lightning/src/ln/channel.rs
index a1982e7..b3cac74 100644
--- a/lightning/src/ln/channel.rs
+++ b/lightning/src/ln/channel.rs
@@ -9265,7 +9265,7 @@ where
onion_routing_packet: (**onion_packet).clone(),
skimmed_fee_msat: htlc.skimmed_fee_msat,
blinding_point: htlc.blinding_point,
- hold_htlc: None, // Will be set by the async sender when support is added
+ hold_htlc: htlc.hold_htlc,
});
}
}
diff --git a/lightning/src/ln/channelmanager.rs b/lightning/src/ln/channelmanager.rs
index bbe36b6..ff92cfc 100644
--- a/lightning/src/ln/channelmanager.rs
+++ b/lightning/src/ln/channelmanager.rs
@@ -4994,6 +4994,7 @@ where
invoice_request: None,
bolt12_invoice: None,
session_priv_bytes,
+ hold_htlc_at_next_hop: false,
})
}
@@ -5009,6 +5010,7 @@ where
invoice_request,
bolt12_invoice,
session_priv_bytes,
+ hold_htlc_at_next_hop,
} = args;
// The top-level caller should hold the total_consistency_lock read lock.
debug_assert!(self.total_consistency_lock.try_write().is_err());
@@ -5098,7 +5100,7 @@ where
htlc_source,
onion_packet,
None,
- false,
+ hold_htlc_at_next_hop,
&self.fee_estimator,
&&logger,
);
@@ -5483,19 +5485,35 @@ where
},
};
- let enqueue_held_htlc_available_res = self.flow.enqueue_held_htlc_available(
- invoice,
- payment_id,
- self.get_peers_for_blinded_path(),
- );
- if enqueue_held_htlc_available_res.is_err() {
- self.abandon_payment_with_reason(
+ // If the call to `Self::hold_htlc_channels` succeeded, then we are a private node and can
+ // hold the HTLCs for this payment at our next-hop channel counterparty until the recipient
+ // comes online. This allows us to go offline after locking in the HTLCs.
+ if let Ok(channels) = self.hold_htlc_channels() {
+ if let Err(e) =
+ self.send_payment_for_static_invoice_no_persist(payment_id, channels, true)
+ {
+ log_trace!(
+ self.logger,
+ "Failed to send held HTLC with payment id {}: {:?}",
+ payment_id,
+ e
+ );
+ }
+ } else {
+ let enqueue_held_htlc_available_res = self.flow.enqueue_held_htlc_available(
+ invoice,
payment_id,
- PaymentFailureReason::BlindedPathCreationFailed,
+ self.get_peers_for_blinded_path(),
);
- res = Err(Bolt12PaymentError::BlindedPathCreationFailed);
- return NotifyOption::DoPersist;
- };
+ if enqueue_held_htlc_available_res.is_err() {
+ self.abandon_payment_with_reason(
+ payment_id,
+ PaymentFailureReason::BlindedPathCreationFailed,
+ );
+ res = Err(Bolt12PaymentError::BlindedPathCreationFailed);
+ return NotifyOption::DoPersist;
+ };
+ }
NotifyOption::DoPersist
});
@@ -5532,7 +5550,7 @@ where
let first_hops = self.list_usable_channels();
PersistenceNotifierGuard::optionally_notify(self, || {
let outbound_pmts_res =
- self.send_payment_for_static_invoice_no_persist(payment_id, first_hops);
+ self.send_payment_for_static_invoice_no_persist(payment_id, first_hops, false);
match outbound_pmts_res {
Err(Bolt12PaymentError::UnexpectedInvoice)
| Err(Bolt12PaymentError::DuplicateInvoice) => {
@@ -5550,11 +5568,12 @@ where
/// Useful if the caller is already triggering a persist of the `ChannelManager`.
fn send_payment_for_static_invoice_no_persist(
- &self, payment_id: PaymentId, first_hops: Vec<ChannelDetails>,
+ &self, payment_id: PaymentId, first_hops: Vec<ChannelDetails>, hold_htlcs_at_next_hop: bool,
) -> Result<(), Bolt12PaymentError> {
let best_block_height = self.best_block.read().unwrap().height;
self.pending_outbound_payments.send_payment_for_static_invoice(
payment_id,
+ hold_htlcs_at_next_hop,
&self.router,
first_hops,
|| self.compute_inflight_htlcs(),
diff --git a/lightning/src/ln/outbound_payment.rs b/lightning/src/ln/outbound_payment.rs
index d751d96..75fe55b 100644
--- a/lightning/src/ln/outbound_payment.rs
+++ b/lightning/src/ln/outbound_payment.rs
@@ -834,6 +834,7 @@ pub(super) struct SendAlongPathArgs<'a> {
pub invoice_request: Option<&'a InvoiceRequest>,
pub bolt12_invoice: Option<&'a PaidBolt12Invoice>,
pub session_priv_bytes: [u8; 32],
+ pub hold_htlc_at_next_hop: bool,
}
pub(super) struct OutboundPayments<L: Deref>
@@ -999,9 +1000,9 @@ where
}
let invoice = PaidBolt12Invoice::Bolt12Invoice(invoice.clone());
self.send_payment_for_bolt12_invoice_internal(
- payment_id, payment_hash, None, None, invoice, route_params, retry_strategy, router, first_hops,
- inflight_htlcs, entropy_source, node_signer, node_id_lookup, secp_ctx, best_block_height,
- pending_events, send_payment_along_path
+ 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
)
}
@@ -1012,7 +1013,7 @@ where
&self, payment_id: PaymentId, payment_hash: PaymentHash,
keysend_preimage: Option<PaymentPreimage>, invoice_request: Option<&InvoiceRequest>,
bolt12_invoice: PaidBolt12Invoice,
- mut route_params: RouteParameters, retry_strategy: Retry, router: &R,
+ mut route_params: RouteParameters, retry_strategy: Retry, hold_htlcs_at_next_hop: bool, router: &R,
first_hops: Vec<ChannelDetails>, inflight_htlcs: IH, entropy_source: &ES, node_signer: &NS,
node_id_lookup: &NL, secp_ctx: &Secp256k1<secp256k1::All>, best_block_height: u32,
pending_events: &Mutex<VecDeque<(events::Event, Option<EventCompletionAction>)>>,
@@ -1097,7 +1098,7 @@ where
let result = self.pay_route_internal(
&route, payment_hash, &recipient_onion, keysend_preimage, invoice_request, Some(&bolt12_invoice), payment_id,
- Some(route_params.final_value_msat), &onion_session_privs, false, node_signer,
+ Some(route_params.final_value_msat), &onion_session_privs, hold_htlcs_at_next_hop, node_signer,
best_block_height, &send_payment_along_path
);
log_info!(
@@ -1231,9 +1232,9 @@ where
IH,
SP,
>(
- &self, payment_id: PaymentId, router: &R, first_hops: Vec<ChannelDetails>,
- inflight_htlcs: IH, entropy_source: &ES, node_signer: &NS, node_id_lookup: &NL,
- secp_ctx: &Secp256k1<secp256k1::All>, best_block_height: u32,
+ &self, payment_id: PaymentId, hold_htlcs_at_next_hop: bool, router: &R,
+ first_hops: Vec<ChannelDetails>, inflight_htlcs: IH, entropy_source: &ES, node_signer: &NS,
+ node_id_lookup: &NL, secp_ctx: &Secp256k1<secp256k1::All>, best_block_height: u32,
pending_events: &Mutex<VecDeque<(events::Event, Option<EventCompletionAction>)>>,
send_payment_along_path: SP,
) -> Result<(), Bolt12PaymentError>
@@ -1249,7 +1250,7 @@ where
payment_hash,
keysend_preimage,
route_params,
- retry_strategy,
+ mut retry_strategy,
invoice_request,
invoice,
) = match self.pending_outbound_payments.lock().unwrap().entry(payment_id) {
@@ -1274,6 +1275,14 @@ where
},
hash_map::Entry::Vacant(_) => return Err(Bolt12PaymentError::UnexpectedInvoice),
};
+
+ // If we expect the HTLCs for this payment to be held at our next-hop counterparty, don't
+ // retry the payment. In future iterations of this feature, we will send this payment via
+ // trampoline and the counterparty will retry on our behalf.
+ if hold_htlcs_at_next_hop {
+ retry_strategy = Retry::Attempts(0);
+ }
+
let invoice = PaidBolt12Invoice::StaticInvoice(invoice);
self.send_payment_for_bolt12_invoice_internal(
payment_id,
@@ -1283,6 +1292,7 @@ where
invoice,
route_params,
retry_strategy,
+ hold_htlcs_at_next_hop,
router,
first_hops,
inflight_htlcs,
@@ -2116,7 +2126,7 @@ where
let path_res = send_payment_along_path(SendAlongPathArgs {
path: &path, payment_hash: &payment_hash, recipient_onion, total_value,
cur_height, payment_id, keysend_preimage: &keysend_preimage, invoice_request,
- bolt12_invoice,
+ bolt12_invoice, hold_htlc_at_next_hop: hold_htlcs_at_next_hop,
session_priv_bytes: *session_priv_bytes
});
results.push(path_res);
Why this scored 27/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.