Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
c41fe2f
Cache peers in OffersMessageFlow
valentinewallace Aug 29, 2025
bbe17ed
f remove limits on peer cache
valentinewallace Sep 4, 2025
b8258aa
Add UpdateAddHTLC::hold_htlc
valentinewallace Aug 15, 2025
f74ce65
Add feature bit for HtlcHold (52/53)
valentinewallace Aug 15, 2025
1653cf2
Add enable_htlc_hold cfg flag + fail hold htlcs
valentinewallace Sep 3, 2025
1878efd
Add RevokeAndACK::release_htlc_message_paths
valentinewallace Aug 19, 2025
324312e
Extract helper for failing HTLC intercepts
valentinewallace Sep 3, 2025
0213a9b
Store held htlcs in pending_intercepted_htlcs
valentinewallace Aug 16, 2025
77719ad
Support creating reply_path for HeldHtlcAvailable
valentinewallace Aug 30, 2025
b5bd601
Include release_held_htlc blinded paths in RAA
valentinewallace Aug 30, 2025
110bfab
Release held htlcs on release_held_htlc
valentinewallace Aug 19, 2025
d3d5116
Add init_features to list_channels filter callback
valentinewallace Aug 21, 2025
c9b77bb
Add HTLCSource::OutboundRoute::hold_htlc
valentinewallace Aug 18, 2025
cd041ed
Add hold_htlcs param to pay_route_internal
valentinewallace Aug 21, 2025
16c262d
Add hold_htlcs field in StaticInvReceived outbounds
valentinewallace Aug 21, 2025
0bebd49
Set UpdateAddHTLC::hold_htlc for offline payees
valentinewallace Aug 21, 2025
5ea8724
Support held_htlc_available counterparty reply path
valentinewallace Aug 29, 2025
b37d047
Send held_htlc_available with counterparty reply path
valentinewallace Aug 29, 2025
a271381
WIP Test async send
valentinewallace Aug 29, 2025
e1fa5eb
Conditionally advertise htlc_hold feature
valentinewallace Sep 5, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions fuzz/src/process_onion_failure.rs
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,7 @@ fn do_test<Out: test_logger::Output>(data: &[u8], out: Out) {
first_hop_htlc_msat: 0,
payment_id,
bolt12_invoice: None,
hold_htlc: None,
};

let failure_len = get_u16!();
Expand Down
17 changes: 15 additions & 2 deletions lightning-types/src/features.rs
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,8 @@
//! (see [BOLT-2](https://github.com/lightning/bolts/blob/master/02-peer-protocol.md#channel-quiescence) for more information).
//! - `ZeroFeeCommitments` - A channel type which always uses zero transaction fee on commitment transactions.
//! (see [BOLT PR #1228](https://github.com/lightning/bolts/pull/1228) for more info).
//! - `HtlcHold` - requires/supports holding HTLCs and forwarding on receipt of an onion message
//! (see [BOLT-2](https://github.com/lightning/bolts/pull/989/files) for more information).
//!
//! LDK knows about the following features, but does not support them:
//! - `AnchorsNonzeroFeeHtlcTx` - the initial version of anchor outputs, which was later found to be
Expand Down Expand Up @@ -161,7 +163,7 @@ mod sealed {
// Byte 5
ProvideStorage | ChannelType | SCIDPrivacy | AnchorZeroFeeCommitments,
// Byte 6
ZeroConf,
ZeroConf | HtlcHold,
// Byte 7
Trampoline | SimpleClose,
]
Expand All @@ -182,7 +184,7 @@ mod sealed {
// Byte 5
ProvideStorage | ChannelType | SCIDPrivacy | AnchorZeroFeeCommitments,
// Byte 6
ZeroConf | Keysend,
ZeroConf | HtlcHold | Keysend,
// Byte 7
Trampoline | SimpleClose,
// Byte 8 - 31
Expand Down Expand Up @@ -640,6 +642,17 @@ mod sealed {
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);
define_feature!(
53,
HtlcHold,
[InitContext, NodeContext],
"Feature flags for holding HTLCs and forwarding on receipt of an onion message",
set_htlc_hold_optional,
set_htlc_hold_required,
clear_htlc_hold,
supports_htlc_hold,
requires_htlc_hold
);
define_feature!(
55,
Keysend,
Expand Down
18 changes: 16 additions & 2 deletions lightning/src/blinded_path/message.rs
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ use crate::blinded_path::{BlindedHop, BlindedPath, Direction, IntroductionNode,
use crate::crypto::streams::ChaChaPolyReadAdapter;
use crate::io;
use crate::io::Cursor;
use crate::ln::channelmanager::PaymentId;
use crate::ln::channelmanager::{InterceptId, PaymentId};
use crate::ln::msgs::DecodeError;
use crate::ln::onion_utils;
use crate::offers::nonce::Nonce;
Expand Down Expand Up @@ -556,7 +556,7 @@ pub enum AsyncPaymentsContext {
},
/// Context contained within the reply [`BlindedMessagePath`] we put in outbound
/// [`HeldHtlcAvailable`] messages, provided back to us in corresponding [`ReleaseHeldHtlc`]
/// messages.
/// messages if we are an always-online sender paying an async recipient.
///
/// [`HeldHtlcAvailable`]: crate::onion_message::async_payments::HeldHtlcAvailable
/// [`ReleaseHeldHtlc`]: crate::onion_message::async_payments::ReleaseHeldHtlc
Expand All @@ -577,6 +577,17 @@ pub enum AsyncPaymentsContext {
/// able to trivially ask if we're online forever.
path_absolute_expiry: core::time::Duration,
},
/// Context contained within the reply [`BlindedMessagePath`] put in outbound
/// [`HeldHtlcAvailable`] messages, provided back to the async sender's always-online counterparty
/// in corresponding [`ReleaseHeldHtlc`] messages.
///
/// [`HeldHtlcAvailable`]: crate::onion_message::async_payments::HeldHtlcAvailable
/// [`ReleaseHeldHtlc`]: crate::onion_message::async_payments::ReleaseHeldHtlc
ReleaseHeldHtlc {
/// An identifier for the HTLC that should be released by us as the sender's always-online
/// channel counterparty to the often-offline recipient.
intercept_id: InterceptId,
},
}

impl_writeable_tlv_based_enum!(MessageContext,
Expand Down Expand Up @@ -632,6 +643,9 @@ impl_writeable_tlv_based_enum!(AsyncPaymentsContext,
(2, invoice_slot, required),
(4, path_absolute_expiry, required),
},
(6, ReleaseHeldHtlc) => {
(0, intercept_id, required),
},
);

/// Contains a simple nonce for use in a blinded path's context.
Expand Down
218 changes: 207 additions & 11 deletions lightning/src/ln/async_payments_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
// You may not use this file except in accordance with one or both of these
// licenses.

use crate::blinded_path::message::{MessageContext, OffersContext};
use crate::blinded_path::message::{MessageContext, NextMessageHop, OffersContext};
use crate::blinded_path::payment::PaymentContext;
use crate::blinded_path::payment::{AsyncBolt12OfferContext, BlindedPaymentTlvs};
use crate::chain::channelmonitor::{HTLC_FAIL_BACK_BUFFER, LATENCY_GRACE_PERIOD_BLOCKS};
Expand Down Expand Up @@ -54,11 +54,12 @@ use crate::sign::NodeSigner;
use crate::sync::Mutex;
use crate::types::features::Bolt12InvoiceFeatures;
use crate::types::payment::{PaymentHash, PaymentPreimage, PaymentSecret};
use crate::util::config::UserConfig;
use crate::util::ser::Writeable;
use bitcoin::constants::ChainHash;
use bitcoin::network::Network;
use bitcoin::secp256k1;
use bitcoin::secp256k1::Secp256k1;
use bitcoin::secp256k1::{PublicKey, Secp256k1};

use core::convert::Infallible;
use core::time::Duration;
Expand Down Expand Up @@ -331,32 +332,114 @@ fn expect_offer_paths_requests(recipient: &Node, next_hop_nodes: &[&Node]) {
// We want to check that the async recipient has enqueued at least one `OfferPathsRequest` and no
// other message types. Check this by iterating through all their outbound onion messages, peeling
// multiple times if the messages are forwarded through other nodes.
let per_msg_recipient_msgs = recipient.onion_messenger.release_pending_msgs();
let offer_paths_reqs = extract_expected_om(recipient, next_hop_nodes, |peeled_onion| {
matches!(
peeled_onion,
PeeledOnion::AsyncPayments(AsyncPaymentsMessage::OfferPathsRequest(_), _, _)
)
});
assert!(!offer_paths_reqs.is_empty());
}

fn extract_invoice_request_om<'a>(
payer: &'a Node, next_hop_nodes: &[&'a Node],
) -> (PublicKey, msgs::OnionMessage) {
extract_expected_om(payer, next_hop_nodes, |peeled_onion| {
matches!(peeled_onion, &PeeledOnion::Offers(OffersMessage::InvoiceRequest(_), _, _))
})
.pop()
.unwrap()
}

fn extract_static_invoice_om<'a>(
invoice_server: &'a Node, next_hop_nodes: &[&'a Node],
) -> (PublicKey, msgs::OnionMessage, StaticInvoice) {
let mut static_invoice = None;
let (peer_id, om) = extract_expected_om(invoice_server, next_hop_nodes, |peeled_onion| {
if let &PeeledOnion::Offers(OffersMessage::StaticInvoice(inv), _, _) = &peeled_onion {
static_invoice = Some(inv.clone());
true
} else {
false
}
})
.pop()
.unwrap();
(peer_id, om, static_invoice.unwrap())
}

fn extract_held_htlc_available_om<'a>(
payer: &'a Node, next_hop_nodes: &[&'a Node],
) -> (PublicKey, msgs::OnionMessage) {
extract_expected_om(payer, next_hop_nodes, |peeled_onion| {
matches!(
peeled_onion,
&PeeledOnion::AsyncPayments(AsyncPaymentsMessage::HeldHtlcAvailable(_), _, _)
)
})
.pop()
.unwrap()
}

fn extract_release_htlc_om<'a>(
recipient: &'a Node, next_hop_nodes: &[&'a Node],
) -> (PublicKey, msgs::OnionMessage) {
extract_expected_om(recipient, next_hop_nodes, |peeled_onion| {
matches!(
peeled_onion,
&PeeledOnion::AsyncPayments(AsyncPaymentsMessage::ReleaseHeldHtlc(_), _, _)
)
})
.pop()
.unwrap()
}

fn extract_expected_om<F>(
msg_sender: &Node, next_hop_nodes: &[&Node], mut expected_msg_type: F,
) -> Vec<(PublicKey, msgs::OnionMessage)>
where
F: FnMut(&PeeledOnion<Infallible>) -> bool,
{
let per_msg_recipient_msgs = msg_sender.onion_messenger.release_pending_msgs();
let mut pk_to_msg = Vec::new();
for (pk, msgs) in per_msg_recipient_msgs {
for msg in msgs {
pk_to_msg.push((pk, msg));
}
}
let mut num_offer_paths_reqs: u8 = 0;
let mut msgs = Vec::new();
while let Some((pk, msg)) = pk_to_msg.pop() {
let node = next_hop_nodes.iter().find(|node| node.node.get_our_node_id() == pk).unwrap();
let peeled_msg = node.onion_messenger.peel_onion_message(&msg).unwrap();
match peeled_msg {
PeeledOnion::AsyncPayments(AsyncPaymentsMessage::OfferPathsRequest(_), _, _) => {
num_offer_paths_reqs += 1;
},
PeeledOnion::Forward(next_hop, msg) => {
let next_pk = match next_hop {
crate::blinded_path::message::NextMessageHop::NodeId(pk) => pk,
_ => panic!(),
NextMessageHop::NodeId(pk) => pk,
NextMessageHop::ShortChannelId(scid) => {
let mut next_pk = None;
for node in next_hop_nodes {
if node.node.get_our_node_id() == pk {
continue;
}
for channel in node.node.list_channels() {
if channel.short_channel_id.unwrap() == scid
|| channel.inbound_scid_alias.unwrap_or(0) == scid
{
next_pk = Some(node.node.get_our_node_id());
}
}
}
next_pk.unwrap()
},
};
pk_to_msg.push((next_pk, msg));
},
_ => panic!("Unexpected message"),
peeled_onion if expected_msg_type(&peeled_onion) => msgs.push((pk, msg)),
peeled_onion => panic!("Unexpected message: {:#?}", peeled_onion),
}
}
assert!(num_offer_paths_reqs > 0);
assert!(!msgs.is_empty());
msgs
}

fn advance_time_by(duration: Duration, node: &Node) {
Expand All @@ -365,6 +448,14 @@ fn advance_time_by(duration: Duration, node: &Node) {
connect_block(node, &block);
}

fn often_offline_node_cfg() -> UserConfig {
let mut cfg = test_default_channel_config();
cfg.channel_handshake_config.announce_for_forwarding = false;
cfg.channel_handshake_limits.force_announced_channel_preference = true;
cfg.hold_outbound_htlcs_at_next_hop = true;
cfg
}

#[test]
fn invalid_keysend_payment_secret() {
let chanmon_cfgs = create_chanmon_cfgs(3);
Expand Down Expand Up @@ -2206,3 +2297,108 @@ fn invoice_server_is_not_channel_peer() {
let res = claim_payment_along_route(ClaimAlongRouteArgs::new(sender, route, keysend_preimage));
assert_eq!(res.0, Some(PaidBolt12Invoice::StaticInvoice(invoice)));
}

#[test]
fn simple_async_sender() {
// Test the basic case of an async sender paying an async recipient.
let chanmon_cfgs = create_chanmon_cfgs(4);
let node_cfgs = create_node_cfgs(4, &chanmon_cfgs);
let (sender_cfg, recipient_cfg) = (often_offline_node_cfg(), often_offline_node_cfg());
let mut invoice_server_cfg = test_default_channel_config();
invoice_server_cfg.accept_forwards_to_priv_channels = true;
let node_chanmgrs = create_node_chanmgrs(
4,
&node_cfgs,
&[Some(sender_cfg), None, Some(invoice_server_cfg), Some(recipient_cfg)],
);
let nodes = create_network(4, &node_cfgs, &node_chanmgrs);
create_unannounced_chan_between_nodes_with_value(&nodes, 0, 1, 1_000_000, 0);
create_announced_chan_between_nodes_with_value(&nodes, 1, 2, 1_000_000, 0);
create_unannounced_chan_between_nodes_with_value(&nodes, 2, 3, 1_000_000, 0);
// Make sure all nodes are at the same block height
let node_max_height =
nodes.iter().map(|node| node.blocks.lock().unwrap().len()).max().unwrap() as u32;
connect_blocks(&nodes[0], node_max_height - nodes[0].best_block_info().1);
connect_blocks(&nodes[1], node_max_height - nodes[1].best_block_info().1);
connect_blocks(&nodes[2], node_max_height - nodes[2].best_block_info().1);
connect_blocks(&nodes[3], node_max_height - nodes[3].best_block_info().1);
let sender = &nodes[0];
let sender_lsp = &nodes[1];
let invoice_server = &nodes[2];
let recipient = &nodes[3];

let recipient_id = vec![42; 32];
let inv_server_paths =
invoice_server.node.blinded_paths_for_async_recipient(recipient_id.clone(), None).unwrap();
Copy link
Contributor

@joostjager joostjager Sep 5, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pre-existing by now, but what is the point of the caller specifying an expiry here? What should they set it to if not None, and how does this help them?

recipient.node.set_paths_to_static_invoice_server(inv_server_paths).unwrap();
expect_offer_paths_requests(recipient, &[sender, sender_lsp, invoice_server]);
let invoice =
pass_static_invoice_server_messages(invoice_server, recipient, recipient_id.clone())
.invoice;

let offer = recipient.node.get_async_receive_offer().unwrap();
let amt_msat = 5000;
let payment_id = PaymentId([1; 32]);
let params = RouteParametersConfig::default();
sender
.node
.pay_for_offer(&offer, None, Some(amt_msat), None, payment_id, Retry::Attempts(0), params)
.unwrap();

// Forward invreq to server, pass static invoice back, check that htlc was locked in/monitor was
// added
let (peer_id, invreq_om) = extract_invoice_request_om(sender, &[sender_lsp, invoice_server]);
invoice_server.onion_messenger.handle_onion_message(peer_id, &invreq_om);

let mut events = invoice_server.node.get_and_clear_pending_events();
assert_eq!(events.len(), 1);
let reply_path = match events.pop().unwrap() {
Event::StaticInvoiceRequested { recipient_id: ev_id, invoice_slot: _, reply_path } => {
assert_eq!(recipient_id, ev_id);
reply_path
},
_ => panic!(),
};

invoice_server.node.send_static_invoice(invoice, reply_path).unwrap();
let (peer_node_id, static_invoice_om, static_invoice) =
extract_static_invoice_om(invoice_server, &[sender_lsp, sender, recipient]);

// The sender should lock in the held HTLC with their LSP right after receiving the static invoice.
sender.onion_messenger.handle_onion_message(peer_node_id, &static_invoice_om);
check_added_monitors(sender, 1);
let commitment_update = get_htlc_update_msgs!(sender, sender_lsp.node.get_our_node_id());
let update_add = commitment_update.update_add_htlcs[0].clone();
let payment_hash = update_add.payment_hash;
assert!(update_add.hold_htlc.is_some());
sender_lsp.node.handle_update_add_htlc(sender.node.get_our_node_id(), &update_add);
commitment_signed_dance!(sender_lsp, sender, &commitment_update.commitment_signed, false, true);

// Ensure that after the held HTLC is locked in, the sender's lsp does not forward it immediately.
sender_lsp.node.process_pending_htlc_forwards();
assert!(sender_lsp.node.get_and_clear_pending_msg_events().is_empty());

let (peer_id, held_htlc_om) =
extract_held_htlc_available_om(sender, &[sender_lsp, invoice_server, recipient]);
recipient.onion_messenger.handle_onion_message(peer_id, &held_htlc_om);
let (peer_id, release_htlc_om) =
extract_release_htlc_om(recipient, &[sender, sender_lsp, invoice_server]);
sender_lsp.onion_messenger.handle_onion_message(peer_id, &release_htlc_om);

// After the sender's LSP receives release_held_htlc from the recipient, the payment can complete
sender_lsp.node.process_pending_htlc_forwards();
let mut events = sender_lsp.node.get_and_clear_pending_msg_events();
assert_eq!(events.len(), 1);
let ev = remove_first_msg_event_to_node(&invoice_server.node.get_our_node_id(), &mut events);
check_added_monitors!(sender_lsp, 1);

let path: &[&Node] = &[invoice_server, recipient];
let args = PassAlongPathArgs::new(sender_lsp, path, amt_msat, payment_hash, ev);
let claimable_ev = do_pass_along_path(args).unwrap();

let route: &[&[&Node]] = &[&[sender_lsp, invoice_server, recipient]];
let keysend_preimage = extract_payment_preimage(&claimable_ev);
let (res, _) =
claim_payment_along_route(ClaimAlongRouteArgs::new(sender, route, keysend_preimage));
assert_eq!(res, Some(PaidBolt12Invoice::StaticInvoice(static_invoice)));
}
1 change: 1 addition & 0 deletions lightning/src/ln/blinded_payment_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1522,6 +1522,7 @@ fn update_add_msg(
onion_routing_packet,
skimmed_fee_msat: None,
blinding_point,
hold_htlc: None,
}
}

Expand Down
Loading
Loading