Add a `payment_metadata` map in blinded payment path contexts
What changed, and why it matters
This commit adds an optional `payment_metadata` field to BOLT 12 blinded payment contexts in the Lightning Dev Kit. It lets payment recipients attach small pieces of custom data (like an order ID) to a payment path, which are then returned to them when the payment arrives. The change is a feature addition, not a fix for a known vulnerability, and the metadata is explicitly treated as opaque data that LDK does not interpret.
No immediate action required; this is a feature commit. Reviewers may want to confirm that downstream callers enforce reasonable size limits on `payment_metadata` before it reaches the onion, since oversized metadata can render payments unpayable or increase probing surface.
Security signals we found
New untrusted data field added to payment onion (payment_metadata)
Documentation warns that large metadata can make payments unpayable
Router is explicitly permitted to modify metadata without breaking payment validity
Serialization uses custom BigSizeKeyedMap with duplicate-key rejection on read
No input validation or size cap enforced in code beyond onion size constraints
Evidence from the diff
The patch introduces payment_metadata: Option<BTreeMap<u64, Vec<u8>>> to Bolt12OfferContext, AsyncBolt12OfferContext, and Bolt12RefundContext. A new BigSizeKeyedMap serializer compresses the map’s keys. The metadata is carried inside ReceiveTlvs, embedded in the payment onion, and surfaced back to the recipient via Event::PaymentClaimable. The Router trait documentation now states that modifying payment_metadata before blinded path construction is allowed and will not break the payment. Tests exercise injection by a router and round-trip delivery through async and normal BOLT 12 flows.
Changed components
lightning/src/blinded_path/payment.rslightning/src/offers/flow.rslightning/src/routing/router.rslightning/src/util/ser.rslightning/src/ln/channelmanager.rsInspect captured patch +368 / −32
diff --git a/fuzz/src/invoice_request_deser.rs b/fuzz/src/invoice_request_deser.rs
index a21303d..c4b3194 100644
--- a/fuzz/src/invoice_request_deser.rs
+++ b/fuzz/src/invoice_request_deser.rs
@@ -104,6 +104,7 @@ fn build_response<T: secp256k1::Signing + secp256k1::Verification>(
let payment_context = PaymentContext::Bolt12Offer(Bolt12OfferContext {
offer_id: OfferId([42; 32]),
invoice_request: invoice_request_fields,
+ payment_metadata: None,
});
let payee_tlvs = ReceiveTlvs {
payment_secret: PaymentSecret([42; 32]),
diff --git a/fuzz/src/refund_deser.rs b/fuzz/src/refund_deser.rs
index 446ac70..c705bda 100644
--- a/fuzz/src/refund_deser.rs
+++ b/fuzz/src/refund_deser.rs
@@ -69,7 +69,8 @@ fn build_response<T: secp256k1::Signing + secp256k1::Verification>(
) -> Result<UnsignedBolt12Invoice, Bolt12SemanticError> {
let entropy_source = Randomness {};
let receive_auth_key = ReceiveAuthKey([41; 32]);
- let payment_context = PaymentContext::Bolt12Refund(Bolt12RefundContext {});
+ let payment_context =
+ PaymentContext::Bolt12Refund(Bolt12RefundContext { payment_metadata: None });
let payee_tlvs = ReceiveTlvs {
payment_secret: PaymentSecret([42; 32]),
payment_constraints: PaymentConstraints {
diff --git a/lightning/src/blinded_path/payment.rs b/lightning/src/blinded_path/payment.rs
index f06c91b..a01ee23 100644
--- a/lightning/src/blinded_path/payment.rs
+++ b/lightning/src/blinded_path/payment.rs
@@ -9,6 +9,8 @@
//! Data structures and methods for constructing [`BlindedPaymentPath`]s to send a payment over.
+use alloc::collections::BTreeMap;
+
use bitcoin::secp256k1::ecdh::SharedSecret;
use bitcoin::secp256k1::{self, PublicKey, Secp256k1, SecretKey};
@@ -29,8 +31,8 @@ use crate::types::features::BlindedHopFeatures;
use crate::types::payment::PaymentSecret;
use crate::types::routing::RoutingFees;
use crate::util::ser::{
- FixedLengthReader, HighZeroBytesDroppedBigSize, LengthReadableArgs, Readable, WithoutLength,
- Writeable, Writer,
+ BigSizeKeyedMap, FixedLengthReader, HighZeroBytesDroppedBigSize, LengthReadableArgs, Readable,
+ WithoutLength, Writeable, Writer,
};
#[allow(unused_imports)]
@@ -572,6 +574,20 @@ pub enum PaymentContext {
/// [`Refund`]: crate::offers::refund::Refund
Bolt12Refund(Bolt12RefundContext),
}
+impl PaymentContext {
+ /// Returns the additional payment metadata stored alongside this payment context, if any.
+ ///
+ /// Payment metadata is stored as a map from a numeric key to an arbitrary byte array value.
+ /// This allows for several types of metadata to be stored attached to a single payment. In the
+ /// future some optional features of LDK may use some keys.
+ pub fn payment_metadata(&self) -> Option<&BTreeMap<u64, Vec<u8>>> {
+ match self {
+ Self::Bolt12Offer(Bolt12OfferContext { payment_metadata, .. })
+ | Self::AsyncBolt12Offer(AsyncBolt12OfferContext { payment_metadata, .. })
+ | Self::Bolt12Refund(Bolt12RefundContext { payment_metadata, .. }) => payment_metadata.as_ref(),
+ }
+ }
+}
// Used when writing PaymentContext in Event::PaymentClaimable to avoid cloning.
pub(crate) enum PaymentContextRef<'a> {
@@ -594,6 +610,27 @@ pub struct Bolt12OfferContext {
/// [`InvoiceRequest`]: crate::offers::invoice_request::InvoiceRequest
/// [`Bolt12Invoice`]: crate::offers::invoice::Bolt12Invoice
pub invoice_request: InvoiceRequestFields,
+
+ /// Additional data about this payment which is not used in LDK and can be used for any
+ /// purpose.
+ ///
+ /// This is analogous to the BOLT 11 [`RecipientOnionFields::payment_metadata`] (which is
+ /// provided to payers via [`Bolt11Invoice::payment_metadata`]) and can be used any time data
+ /// needs to be "stored" by a payment recipient for their own internal use, provided back to
+ /// them with the payment.
+ ///
+ /// Payment metadata is stored as a map from a numeric key to an arbitrary byte array value.
+ /// This allows for several types of metadata to be stored attached to a single payment. In the
+ /// future some optional features of LDK may use some keys. For the sake of conflict
+ /// reduction, those features will attempt to use keys in the range 128-256.
+ ///
+ /// Note that because this is included in the payment onion, its size must be tightly
+ /// constrained. More than a few hundred bytes and the payment will be entirely unpayable (with
+ /// limited routing options as size increases).
+ ///
+ /// [`RecipientOnionFields::payment_metadata`]: crate::ln::outbound_payment::RecipientOnionFields::payment_metadata
+ /// [`Bolt11Invoice::payment_metadata`]: lightning_invoice::Bolt11Invoice::payment_metadata
+ pub payment_metadata: Option<BTreeMap<u64, Vec<u8>>>,
}
/// The context of a payment made for a static invoice requested from a BOLT 12 [`Offer`].
@@ -606,13 +643,55 @@ pub struct AsyncBolt12OfferContext {
///
/// [`InvoiceRequest`]: crate::offers::invoice_request::InvoiceRequest
pub offer_nonce: Nonce,
+
+ /// Additional data about this payment which is not used in LDK and can be used for any
+ /// purpose.
+ ///
+ /// This is analogous to the BOLT 11 [`RecipientOnionFields::payment_metadata`] (which is
+ /// provided to payers via [`Bolt11Invoice::payment_metadata`]) and can be used any time data
+ /// needs to be "stored" by a payment recipient for their own internal use, provided back to
+ /// them with the payment.
+ ///
+ /// Payment metadata is stored as a map from a numeric key to an arbitrary byte array value.
+ /// This allows for several types of metadata to be stored attached to a single payment. In the
+ /// future some optional features of LDK may use some keys. For the sake of conflict
+ /// reduction, those features will attempt to use keys in the range 128-256.
+ ///
+ /// Note that because this is included in the payment onion, its size must be tightly
+ /// constrained. More than a few hundred bytes and the payment will be entirely unpayable (with
+ /// limited routing options as size increases).
+ ///
+ /// [`RecipientOnionFields::payment_metadata`]: crate::ln::outbound_payment::RecipientOnionFields::payment_metadata
+ /// [`Bolt11Invoice::payment_metadata`]: lightning_invoice::Bolt11Invoice::payment_metadata
+ pub payment_metadata: Option<BTreeMap<u64, Vec<u8>>>,
}
/// The context of a payment made for an invoice sent for a BOLT 12 [`Refund`].
///
/// [`Refund`]: crate::offers::refund::Refund
#[derive(Clone, Debug, Eq, PartialEq)]
-pub struct Bolt12RefundContext {}
+pub struct Bolt12RefundContext {
+ /// Additional data about this payment which is not used in LDK and can be used for any
+ /// purpose.
+ ///
+ /// This is analogous to the BOLT 11 [`RecipientOnionFields::payment_metadata`] (which is
+ /// provided to payers via [`Bolt11Invoice::payment_metadata`]) and can be used any time data
+ /// needs to be "stored" by a payment recipient for their own internal use, provided back to
+ /// them with the payment.
+ ///
+ /// Payment metadata is stored as a map from a numeric key to an arbitrary byte array value.
+ /// This allows for several types of metadata to be stored attached to a single payment. In the
+ /// future some optional features of LDK may use some keys. For the sake of conflict
+ /// reduction, those features will attempt to use keys in the range 128-256.
+ ///
+ /// Note that because this is included in the payment onion, its size must be tightly
+ /// constrained. More than a few hundred bytes and the payment will be entirely unpayable (with
+ /// limited routing options as size increases).
+ ///
+ /// [`RecipientOnionFields::payment_metadata`]: crate::ln::outbound_payment::RecipientOnionFields::payment_metadata
+ /// [`Bolt11Invoice::payment_metadata`]: lightning_invoice::Bolt11Invoice::payment_metadata
+ pub payment_metadata: Option<BTreeMap<u64, Vec<u8>>>,
+}
impl TryFrom<CounterpartyForwardingInfo> for PaymentRelay {
type Error = ();
@@ -1031,14 +1110,18 @@ impl<'a> Writeable for PaymentContextRef<'a> {
impl_writeable_tlv_based!(Bolt12OfferContext, {
(0, offer_id, required),
+ (1, payment_metadata, (option, encoding: (BTreeMap<u64, Vec<u8>>, BigSizeKeyedMap))),
(2, invoice_request, required),
});
impl_writeable_tlv_based!(AsyncBolt12OfferContext, {
(0, offer_nonce, required),
+ (1, payment_metadata, (option, encoding: (BTreeMap<u64, Vec<u8>>, BigSizeKeyedMap))),
});
-impl_writeable_tlv_based!(Bolt12RefundContext, {});
+impl_writeable_tlv_based!(Bolt12RefundContext, {
+ (1, payment_metadata, (option, encoding: (BTreeMap<u64, Vec<u8>>, BigSizeKeyedMap))),
+});
#[cfg(test)]
mod tests {
@@ -1097,7 +1180,9 @@ mod tests {
let recv_tlvs = ReceiveTlvs {
payment_secret: PaymentSecret([0; 32]),
payment_constraints: PaymentConstraints { max_cltv_expiry: 0, htlc_minimum_msat: 1 },
- payment_context: PaymentContext::Bolt12Refund(Bolt12RefundContext {}),
+ payment_context: PaymentContext::Bolt12Refund(Bolt12RefundContext {
+ payment_metadata: None,
+ }),
};
let htlc_maximum_msat = 100_000;
let blinded_payinfo =
@@ -1115,7 +1200,9 @@ mod tests {
let recv_tlvs = ReceiveTlvs {
payment_secret: PaymentSecret([0; 32]),
payment_constraints: PaymentConstraints { max_cltv_expiry: 0, htlc_minimum_msat: 1 },
- payment_context: PaymentContext::Bolt12Refund(Bolt12RefundContext {}),
+ payment_context: PaymentContext::Bolt12Refund(Bolt12RefundContext {
+ payment_metadata: None,
+ }),
};
let blinded_payinfo = super::compute_payinfo::<ForwardTlvs>(
&[],
@@ -1178,7 +1265,9 @@ mod tests {
let recv_tlvs = ReceiveTlvs {
payment_secret: PaymentSecret([0; 32]),
payment_constraints: PaymentConstraints { max_cltv_expiry: 0, htlc_minimum_msat: 3 },
- payment_context: PaymentContext::Bolt12Refund(Bolt12RefundContext {}),
+ payment_context: PaymentContext::Bolt12Refund(Bolt12RefundContext {
+ payment_metadata: None,
+ }),
};
let htlc_maximum_msat = 100_000;
let blinded_payinfo = super::compute_payinfo(
@@ -1238,7 +1327,9 @@ mod tests {
let recv_tlvs = ReceiveTlvs {
payment_secret: PaymentSecret([0; 32]),
payment_constraints: PaymentConstraints { max_cltv_expiry: 0, htlc_minimum_msat: 1 },
- payment_context: PaymentContext::Bolt12Refund(Bolt12RefundContext {}),
+ payment_context: PaymentContext::Bolt12Refund(Bolt12RefundContext {
+ payment_metadata: None,
+ }),
};
let htlc_minimum_msat = 3798;
assert!(super::compute_payinfo(
@@ -1309,7 +1400,9 @@ mod tests {
let recv_tlvs = ReceiveTlvs {
payment_secret: PaymentSecret([0; 32]),
payment_constraints: PaymentConstraints { max_cltv_expiry: 0, htlc_minimum_msat: 1 },
- payment_context: PaymentContext::Bolt12Refund(Bolt12RefundContext {}),
+ payment_context: PaymentContext::Bolt12Refund(Bolt12RefundContext {
+ payment_metadata: None,
+ }),
};
let blinded_payinfo = super::compute_payinfo(
diff --git a/lightning/src/ln/async_payments_tests.rs b/lightning/src/ln/async_payments_tests.rs
index bd07d13..817e130 100644
--- a/lightning/src/ln/async_payments_tests.rs
+++ b/lightning/src/ln/async_payments_tests.rs
@@ -7,6 +7,8 @@
// You may not use this file except in accordance with one or both of these
// licenses.
+use alloc::collections::BTreeMap;
+
use crate::blinded_path::message::{
BlindedMessagePath, MessageContext, NextMessageHop, OffersContext,
};
@@ -299,6 +301,7 @@ fn create_static_invoice_builder<'a>(
relative_expiry_secs,
recipient.node.list_usable_channels(),
recipient.node.test_get_peers_for_blinded_path(),
+ None,
)
.unwrap()
}
@@ -1150,6 +1153,88 @@ fn async_receive_flow_success() {
assert_eq!(res, Some(PaidBolt12Invoice::StaticInvoice(static_invoice)));
}
+#[test]
+fn async_payment_delivers_payment_metadata() {
+ // Test that `payment_metadata` set in the `AsyncBolt12OfferContext` of a static invoice's
+ // blinded payment paths is surfaced via `Event::PaymentClaimable` when the async recipient
+ // receives the keysend payment.
+ let chanmon_cfgs = create_chanmon_cfgs(3);
+ let node_cfgs = create_node_cfgs(3, &chanmon_cfgs);
+
+ let mut allow_priv_chan_fwds_cfg = test_default_channel_config();
+ allow_priv_chan_fwds_cfg.accept_forwards_to_priv_channels = true;
+ let node_chanmgrs =
+ create_node_chanmgrs(3, &node_cfgs, &[None, Some(allow_priv_chan_fwds_cfg), None]);
+
+ let nodes = create_network(3, &node_cfgs, &node_chanmgrs);
+ create_announced_chan_between_nodes_with_value(&nodes, 0, 1, 1_000_000, 0);
+ create_unannounced_chan_between_nodes_with_value(&nodes, 1, 2, 1_000_000, 0);
+
+ let recipient_id = vec![42; 32];
+ let inv_server_paths =
+ nodes[1].node.blinded_paths_for_async_recipient(recipient_id.clone(), None).unwrap();
+ nodes[2].node.set_paths_to_static_invoice_server(inv_server_paths).unwrap();
+ expect_offer_paths_requests(&nodes[2], &[&nodes[0], &nodes[1]]);
+
+ // Configure the recipient's router to inject `payment_metadata` into the
+ // `AsyncBolt12OfferContext` of the static invoice's blinded payment paths. The
+ // `pass_static_invoice_server_messages` flow below builds the static invoice via this router,
+ // at which point the override is consumed.
+ let mut expected_metadata = BTreeMap::new();
+ expected_metadata.insert(0u64, vec![1, 2, 3, 4]);
+ expected_metadata.insert(7u64, vec![0xab, 0xcd]);
+ nodes[2].router.set_next_payment_context_metadata(expected_metadata.clone());
+
+ let invoice_flow_res =
+ pass_static_invoice_server_messages(&nodes[1], &nodes[2], recipient_id.clone());
+ let static_invoice = invoice_flow_res.invoice;
+ let offer = nodes[2].node.get_async_receive_offer().unwrap();
+ let amt_msat = 5000;
+ let payment_id = PaymentId([1; 32]);
+ nodes[0].node.pay_for_offer(&offer, Some(amt_msat), payment_id, Default::default()).unwrap();
+ let release_held_htlc_om = pass_async_payments_oms(
+ static_invoice.clone(),
+ &nodes[0],
+ &nodes[1],
+ &nodes[2],
+ recipient_id,
+ invoice_flow_res.invoice_request_path,
+ )
+ .1;
+ nodes[0]
+ .onion_messenger
+ .handle_onion_message(nodes[2].node.get_our_node_id(), &release_held_htlc_om);
+
+ let mut events = nodes[0].node.get_and_clear_pending_msg_events();
+ assert_eq!(events.len(), 1);
+ let ev = remove_first_msg_event_to_node(&nodes[1].node.get_our_node_id(), &mut events);
+ let payment_hash = extract_payment_hash(&ev);
+ check_added_monitors(&nodes[0], 1);
+
+ let route: &[&[&Node]] = &[&[&nodes[1], &nodes[2]]];
+ let args = PassAlongPathArgs::new(&nodes[0], route[0], amt_msat, payment_hash, ev)
+ .with_dummy_tlvs(&[DummyTlvs::default(); DEFAULT_PAYMENT_DUMMY_HOPS]);
+ let claimable_ev = do_pass_along_path(args).unwrap();
+
+ // Verify the `payment_metadata` we injected is surfaced via the `Bolt12OfferContext` of
+ // the `PaymentPurpose`. The recipient converts `AsyncBolt12OfferContext` to
+ // `Bolt12OfferContext` when constructing the `PaymentPurpose` for keysend payments.
+ match &claimable_ev {
+ Event::PaymentClaimable {
+ purpose: PaymentPurpose::Bolt12OfferPayment { payment_context, .. },
+ ..
+ } => {
+ assert_eq!(payment_context.payment_metadata.as_ref(), Some(&expected_metadata));
+ },
+ _ => panic!("Unexpected event: {:?}", claimable_ev),
+ }
+
+ let keysend_preimage = extract_payment_preimage(&claimable_ev);
+ let (res, _) =
+ claim_payment_along_route(ClaimAlongRouteArgs::new(&nodes[0], route, keysend_preimage));
+ assert_eq!(res, Some(PaidBolt12Invoice::StaticInvoice(static_invoice)));
+}
+
#[cfg_attr(feature = "std", ignore)]
#[test]
fn expired_static_invoice_fail() {
@@ -1591,6 +1676,7 @@ fn reject_bad_payment_secret() {
PaymentContext::AsyncBolt12Offer(AsyncBolt12OfferContext {
// We don't reach the point of checking the invreq nonce due to the invalid payment secret
offer_nonce: Nonce([i; Nonce::LENGTH]),
+ payment_metadata: None,
}),
u32::MAX,
)
@@ -3123,7 +3209,10 @@ fn intercepted_hold_htlc() {
.unwrap();
let mut offer_nonce = Nonce([0; Nonce::LENGTH]);
offer_nonce.0.copy_from_slice(&hardcoded_random_bytes[..Nonce::LENGTH]);
- let payment_context = PaymentContext::AsyncBolt12Offer(AsyncBolt12OfferContext { offer_nonce });
+ let payment_context = PaymentContext::AsyncBolt12Offer(AsyncBolt12OfferContext {
+ offer_nonce,
+ payment_metadata: None,
+ });
let blinded_payment_path_with_jit_channel_scid = recipient
.node
.flow
diff --git a/lightning/src/ln/blinded_payment_tests.rs b/lightning/src/ln/blinded_payment_tests.rs
index 621c510..32c0709 100644
--- a/lightning/src/ln/blinded_payment_tests.rs
+++ b/lightning/src/ln/blinded_payment_tests.rs
@@ -83,7 +83,7 @@ pub fn blinded_payment_path(
htlc_minimum_msat:
intro_node_min_htlc_opt.unwrap_or_else(|| channel_upds.last().unwrap().htlc_minimum_msat),
},
- payment_context: PaymentContext::Bolt12Refund(Bolt12RefundContext {}),
+ payment_context: PaymentContext::Bolt12Refund(Bolt12RefundContext { payment_metadata: None }),
};
let receive_auth_key = keys_manager.get_receive_auth_key();
@@ -172,7 +172,7 @@ fn do_one_hop_blinded_path(success: bool) {
max_cltv_expiry: u32::max_value(),
htlc_minimum_msat: chan_upd.htlc_minimum_msat,
},
- payment_context: PaymentContext::Bolt12Refund(Bolt12RefundContext {}),
+ payment_context: PaymentContext::Bolt12Refund(Bolt12RefundContext { payment_metadata: None }),
};
let receive_auth_key = chanmon_cfgs[1].keys_manager.get_receive_auth_key();
@@ -216,7 +216,9 @@ fn one_hop_blinded_path_with_dummy_hops() {
max_cltv_expiry: u32::max_value(),
htlc_minimum_msat: chan_upd.htlc_minimum_msat,
},
- payment_context: PaymentContext::Bolt12Refund(Bolt12RefundContext {}),
+ payment_context: PaymentContext::Bolt12Refund(Bolt12RefundContext {
+ payment_metadata: None,
+ }),
};
let receive_auth_key = chanmon_cfgs[1].keys_manager.get_receive_auth_key();
let dummy_tlvs = [DummyTlvs::default(); 2];
@@ -296,7 +298,7 @@ fn mpp_to_one_hop_blinded_path() {
max_cltv_expiry: u32::max_value(),
htlc_minimum_msat: chan_upd_1_3.htlc_minimum_msat,
},
- payment_context: PaymentContext::Bolt12Refund(Bolt12RefundContext {}),
+ payment_context: PaymentContext::Bolt12Refund(Bolt12RefundContext { payment_metadata: None }),
};
let receive_auth_key = chanmon_cfgs[3].keys_manager.get_receive_auth_key();
let blinded_path = BlindedPaymentPath::new(
@@ -1419,7 +1421,7 @@ fn custom_tlvs_to_blinded_path() {
max_cltv_expiry: u32::max_value(),
htlc_minimum_msat: chan_upd.htlc_minimum_msat,
},
- payment_context: PaymentContext::Bolt12Refund(Bolt12RefundContext {}),
+ payment_context: PaymentContext::Bolt12Refund(Bolt12RefundContext { payment_metadata: None }),
};
let receive_auth_key = chanmon_cfgs[1].keys_manager.get_receive_auth_key();
@@ -1473,7 +1475,7 @@ fn fails_receive_tlvs_authentication() {
max_cltv_expiry: u32::max_value(),
htlc_minimum_msat: chan_upd.htlc_minimum_msat,
},
- payment_context: PaymentContext::Bolt12Refund(Bolt12RefundContext {}),
+ payment_context: PaymentContext::Bolt12Refund(Bolt12RefundContext { payment_metadata: None }),
};
let receive_auth_key = chanmon_cfgs[1].keys_manager.get_receive_auth_key();
@@ -1503,7 +1505,7 @@ fn fails_receive_tlvs_authentication() {
max_cltv_expiry: u32::max_value(),
htlc_minimum_msat: chan_upd.htlc_minimum_msat,
},
- payment_context: PaymentContext::Bolt12Refund(Bolt12RefundContext {}),
+ payment_context: PaymentContext::Bolt12Refund(Bolt12RefundContext { payment_metadata: None }),
};
// Use a mismatched ReceiveAuthKey to force auth failure:
let mismatched_receive_auth_key = ReceiveAuthKey([0u8; 32]);
@@ -2286,7 +2288,7 @@ fn do_test_trampoline_single_hop_receive(success: bool) {
max_cltv_expiry: u32::max_value(),
htlc_minimum_msat: amt_msat,
},
- payment_context: PaymentContext::Bolt12Refund(Bolt12RefundContext {}),
+ payment_context: PaymentContext::Bolt12Refund(Bolt12RefundContext { payment_metadata: None }),
};
let receive_auth_key = nodes[2].keys_manager.get_receive_auth_key();
let blinded_path = BlindedPaymentPath::new(&[], carol_node_id, receive_auth_key, payee_tlvs, u64::MAX, 0, nodes[2].keys_manager, &secp_ctx).unwrap();
@@ -2607,7 +2609,9 @@ fn do_test_trampoline_relay(blinded: bool, test_case: TrampolineTestCase) {
max_cltv_expiry: u32::max_value(),
htlc_minimum_msat: original_amt_msat,
},
- payment_context: PaymentContext::Bolt12Refund(Bolt12RefundContext {}),
+ payment_context: PaymentContext::Bolt12Refund(Bolt12RefundContext {
+ payment_metadata: None,
+ }),
},
original_trampoline_cltv,
excess_final_cltv,
diff --git a/lightning/src/ln/channelmanager.rs b/lightning/src/ln/channelmanager.rs
index 1f32423..9ceae85 100644
--- a/lightning/src/ln/channelmanager.rs
+++ b/lightning/src/ln/channelmanager.rs
@@ -8665,7 +8665,7 @@ impl<
},
OnionPayload::Spontaneous(keysend_preimage) => {
let purpose = if let Some(PaymentContext::AsyncBolt12Offer(
- AsyncBolt12OfferContext { offer_nonce },
+ AsyncBolt12OfferContext { offer_nonce, payment_metadata },
)) = payment_context
{
let payment_data = match payment_data {
@@ -8707,6 +8707,7 @@ impl<
PaymentContext::Bolt12Offer(Bolt12OfferContext {
offer_id: verified_invreq.offer_id(),
invoice_request: verified_invreq.fields(),
+ payment_metadata,
});
let from_parts_res = events::PaymentPurpose::from_parts(
Some(keysend_preimage),
@@ -14933,6 +14934,7 @@ impl<
self.create_inbound_payment(Some(amount_msats), relative_expiry, None)
.map_err(|()| Bolt12SemanticError::InvalidAmount)
},
+ None,
)?;
let invoice = builder.allow_mpp().build_and_sign(secp_ctx)?;
@@ -17117,6 +17119,7 @@ impl<
&request,
self.list_usable_channels(),
get_payment_info,
+ None,
);
match result {
@@ -17141,6 +17144,7 @@ impl<
&request,
self.list_usable_channels(),
get_payment_info,
+ None,
);
match result {
diff --git a/lightning/src/ln/max_payment_path_len_tests.rs b/lightning/src/ln/max_payment_path_len_tests.rs
index 0515a52..4d0abb6 100644
--- a/lightning/src/ln/max_payment_path_len_tests.rs
+++ b/lightning/src/ln/max_payment_path_len_tests.rs
@@ -222,7 +222,9 @@ fn one_hop_blinded_path_with_custom_tlv() {
max_cltv_expiry: u32::max_value(),
htlc_minimum_msat: chan_upd_1_2.htlc_minimum_msat,
},
- payment_context: PaymentContext::Bolt12Refund(Bolt12RefundContext {}),
+ payment_context: PaymentContext::Bolt12Refund(Bolt12RefundContext {
+ payment_metadata: None,
+ }),
};
let receive_auth_key = chanmon_cfgs[2].keys_manager.get_receive_auth_key();
let mut secp_ctx = Secp256k1::new();
diff --git a/lightning/src/ln/offers_tests.rs b/lightning/src/ln/offers_tests.rs
index de08af5..d1ec9b4 100644
--- a/lightning/src/ln/offers_tests.rs
+++ b/lightning/src/ln/offers_tests.rs
@@ -42,6 +42,8 @@
//! Nodes without channels are disconnected and connected as needed to ensure that deterministic
//! blinded paths are used.
+use alloc::collections::BTreeMap;
+
use bitcoin::network::Network;
use bitcoin::secp256k1::{PublicKey, Secp256k1};
use core::time::Duration;
@@ -728,6 +730,7 @@ fn creates_and_pays_for_offer_using_two_hop_blinded_path() {
payer_note_truncated: None,
human_readable_name: None,
},
+ payment_metadata: None,
});
assert_eq!(invoice_request.amount_msats(), Some(10_000_000));
assert_ne!(invoice_request.payer_signing_pubkey(), david_id);
@@ -814,7 +817,7 @@ fn creates_and_pays_for_refund_using_two_hop_blinded_path() {
}
expect_recent_payment!(david, RecentPaymentDetails::AwaitingInvoice, payment_id);
- let payment_context = PaymentContext::Bolt12Refund(Bolt12RefundContext {});
+ let payment_context = PaymentContext::Bolt12Refund(Bolt12RefundContext { payment_metadata: None });
let expected_invoice = alice.node.request_refund_payment(&refund).unwrap();
connect_peers(alice, charlie);
@@ -886,6 +889,7 @@ fn creates_and_pays_for_offer_using_one_hop_blinded_path() {
payer_note_truncated: None,
human_readable_name: None,
},
+ payment_metadata: None,
});
assert_eq!(invoice_request.amount_msats(), Some(10_000_000));
assert_ne!(invoice_request.payer_signing_pubkey(), bob_id);
@@ -910,6 +914,75 @@ fn creates_and_pays_for_offer_using_one_hop_blinded_path() {
expect_recent_payment!(bob, RecentPaymentDetails::Fulfilled, payment_id);
}
+/// Checks that a `Router` can attach `payment_metadata` to the [`PaymentContext`] of a blinded
+/// payment path while building it in response to an invoice request, and that the metadata is
+/// surfaced back via [`Event::PaymentClaimable`] when the payment is received.
+#[test]
+fn router_modifies_payment_metadata_in_blinded_path() {
+ let chanmon_cfgs = create_chanmon_cfgs(2);
+ let node_cfgs = create_node_cfgs(2, &chanmon_cfgs);
+ let node_chanmgrs = create_node_chanmgrs(2, &node_cfgs, &[None, None]);
+ let nodes = create_network(2, &node_cfgs, &node_chanmgrs);
+
+ create_announced_chan_between_nodes_with_value(&nodes, 0, 1, 10_000_000, 1_000_000_000);
+
+ let alice = &nodes[0];
+ let alice_id = alice.node.get_our_node_id();
+ let bob = &nodes[1];
+ let bob_id = bob.node.get_our_node_id();
+
+ // Configure Alice's router to inject `payment_metadata` into the `PaymentContext` of the
+ // `ReceiveTlvs` it builds blinded payment paths from. This simulates a recipient-side router
+ // that ties extra recipient data (e.g. an order ID) to the blinded path created in response to
+ // an inbound invoice request.
+ let mut expected_metadata = BTreeMap::new();
+ expected_metadata.insert(0u64, vec![1, 2, 3, 4]);
+ expected_metadata.insert(7u64, vec![0xab, 0xcd]);
+ alice.router.set_next_payment_context_metadata(expected_metadata.clone());
+
+ let offer = alice.node
+ .create_offer_builder().unwrap()
+ .amount_msats(10_000_000)
+ .build().unwrap();
+
+ let payment_id = PaymentId([1; 32]);
+ bob.node.pay_for_offer(&offer, None, payment_id, Default::default()).unwrap();
+ expect_recent_payment!(bob, RecentPaymentDetails::AwaitingInvoice, payment_id);
+
+ // Bob -> Alice: invoice_request. When Alice handles it, her flow asks the router for blinded
+ // payment paths; the router applies the configured metadata override before the path is built
+ // and embedded in the invoice.
+ let onion_message = bob.onion_messenger.next_onion_message_for_peer(alice_id).unwrap();
+ alice.onion_messenger.handle_onion_message(bob_id, &onion_message);
+
+ let (invoice_request, _) = extract_invoice_request(alice, &onion_message);
+
+ // Alice -> Bob: invoice (carrying the blinded path with the modified payment_context).
+ let onion_message = alice.onion_messenger.next_onion_message_for_peer(bob_id).unwrap();
+ bob.onion_messenger.handle_onion_message(alice_id, &onion_message);
+
+ let (invoice, _) = extract_invoice(bob, &onion_message);
+
+ let payment_context = PaymentContext::Bolt12Offer(Bolt12OfferContext {
+ offer_id: offer.id(),
+ invoice_request: InvoiceRequestFields {
+ payer_signing_pubkey: invoice_request.payer_signing_pubkey(),
+ quantity: None,
+ payer_note_truncated: None,
+ human_readable_name: None,
+ },
+ payment_metadata: Some(expected_metadata),
+ });
+
+ route_bolt12_payment(bob, &[alice], &invoice);
+ expect_recent_payment!(bob, RecentPaymentDetails::Pending, payment_id);
+
+ // Verifies that Alice's `Event::PaymentClaimable` carries the `payment_metadata` injected by
+ // the router (via the `expected_payment_context` equality check inside this helper).
+ claim_bolt12_payment(bob, &[alice], payment_context, &invoice);
+ expect_recent_payment!(bob, RecentPaymentDetails::Fulfilled, payment_id);
+}
+
/// Checks that a refund can be paid through a one-hop blinded path and that ephemeral pubkeys are
/// used rather than exposing a node's pubkey. However, the node's pubkey is still used as the
/// introduction node of the blinded path.
@@ -942,7 +1015,7 @@ fn creates_and_pays_for_refund_using_one_hop_blinded_path() {
}
expect_recent_payment!(bob, RecentPaymentDetails::AwaitingInvoice, payment_id);
- let payment_context = PaymentContext::Bolt12Refund(Bolt12RefundContext {});
+ let payment_context = PaymentContext::Bolt12Refund(Bolt12RefundContext { payment_metadata: None });
let expected_invoice = alice.node.request_refund_payment(&refund).unwrap();
let onion_message = alice.onion_messenger.next_onion_message_for_peer(bob_id).unwrap();
@@ -1007,6 +1080,7 @@ fn pays_for_offer_without_blinded_paths() {
payer_note_truncated: None,
human_readable_name: None,
},
+ payment_metadata: None,
});
let onion_message = alice.onion_messenger.next_onion_message_for_peer(bob_id).unwrap();
@@ -1047,7 +1121,7 @@ fn pays_for_refund_without_blinded_paths() {
assert!(refund.paths().is_empty());
expect_recent_payment!(bob, RecentPaymentDetails::AwaitingInvoice, payment_id);
- let payment_context = PaymentContext::Bolt12Refund(Bolt12RefundContext {});
+ let payment_context = PaymentContext::Bolt12Refund(Bolt12RefundContext { payment_metadata: None });
let expected_invoice = alice.node.request_refund_payment(&refund).unwrap();
let onion_message = alice.onion_messenger.next_onion_message_for_peer(bob_id).unwrap();
@@ -1275,6 +1349,7 @@ fn creates_and_pays_for_offer_with_retry() {
payer_note_truncated: None,
human_readable_name: None,
},
+ payment_metadata: None,
});
assert_eq!(invoice_request.amount_msats(), Some(10_000_000));
assert_ne!(invoice_request.payer_signing_pubkey(), bob_id);
@@ -1340,6 +1415,7 @@ fn pays_bolt12_invoice_asynchronously() {
payer_note_truncated: None,
human_readable_name: None,
},
+ payment_metadata: None,
});
let onion_message = alice.onion_messenger.next_onion_message_for_peer(bob_id).unwrap();
@@ -1437,6 +1513,7 @@ fn creates_offer_with_blinded_path_using_unannounced_introduction_node() {
payer_note_truncated: None,
human_readable_name: None,
},
+ payment_metadata: None,
});
assert_ne!(invoice_request.payer_signing_pubkey(), bob_id);
assert_eq!(reply_path.introduction_node(), &IntroductionNode::NodeId(alice_id));
@@ -2280,7 +2357,7 @@ fn fails_paying_invoice_more_than_once() {
david.onion_messenger.handle_onion_message(charlie_id, &onion_message);
// David initiates paying the first invoice
- let payment_context = PaymentContext::Bolt12Refund(Bolt12RefundContext {});
+ let payment_context = PaymentContext::Bolt12Refund(Bolt12RefundContext { payment_metadata: None });
let (invoice1, _) = extract_invoice(david, &onion_message);
route_bolt12_payment(david, &[charlie, bob, alice], &invoice1);
@@ -2648,6 +2725,7 @@ fn creates_and_pays_for_phantom_offer() {
payer_note_truncated: None,
human_readable_name: None,
},
+ payment_metadata: None,
});
let onion_message =
diff --git a/lightning/src/offers/flow.rs b/lightning/src/offers/flow.rs
index 6c1b7a5..e3bf66c 100644
--- a/lightning/src/offers/flow.rs
+++ b/lightning/src/offers/flow.rs
@@ -10,6 +10,8 @@
//! Provides data structures and functions for creating and managing Offers messages,
//! facilitating communication, and handling BOLT12 messages and payments.
+use alloc::collections::BTreeMap;
+
use core::sync::atomic::{AtomicUsize, Ordering};
use core::time::Duration;
@@ -828,13 +830,15 @@ impl<MR: MessageRouter, L: Logger> OffersMessageFlow<MR, L> {
pub fn create_static_invoice_builder<'a, R: Router>(
&self, router: &R, offer: &'a Offer, offer_nonce: Nonce, payment_secret: PaymentSecret,
relative_expiry_secs: u32, usable_channels: Vec<ChannelDetails>,
- peers: Vec<MessageForwardNode>,
+ peers: Vec<MessageForwardNode>, payment_metadata: Option<BTreeMap<u64, Vec<u8>>>,
) -> Result<StaticInvoiceBuilder<'a>, Bolt12SemanticError> {
let expanded_key = &self.inbound_payment_key;
let secp_ctx = &self.secp_ctx;
- let payment_context =
- PaymentContext::AsyncBolt12Offer(AsyncBolt12OfferContext { offer_nonce });
+ let payment_context = PaymentContext::AsyncBolt12Offer(AsyncBolt12OfferContext {
+ offer_nonce,
+ payment_metadata,
+ });
let amount_msat = offer.amount().and_then(|amount| match amount {
Amount::Bitcoin { amount_msats } => Some(amount_msats),
@@ -896,6 +900,7 @@ impl<MR: MessageRouter, L: Logger> OffersMessageFlow<MR, L> {
pub fn create_invoice_builder_from_refund<'a, ES: EntropySource, R: Router, F>(
&'a self, router: &R, entropy_source: ES, refund: &'a Refund,
usable_channels: Vec<ChannelDetails>, get_payment_info: F,
+ payment_metadata: Option<BTreeMap<u64, Vec<u8>>>,
) -> Result<InvoiceBuilder<'a, DerivedSigningPubkey>, Bolt12SemanticError>
where
F: Fn(u64, u32) -> Result<(PaymentHash, PaymentSecret), Bolt12SemanticError>,
@@ -912,7 +917,8 @@ impl<MR: MessageRouter, L: Logger> OffersMessageFlow<MR, L> {
let (payment_hash, payment_secret) = get_payment_info(amount_msats, relative_expiry)?;
- let payment_context = PaymentContext::Bolt12Refund(Bolt12RefundContext {});
+ let payment_context =
+ PaymentContext::Bolt12Refund(Bolt12RefundContext { payment_metadata });
let payment_paths = self
.create_blinded_payment_paths(
router,
@@ -963,6 +969,7 @@ impl<MR: MessageRouter, L: Logger> OffersMessageFlow<MR, L> {
pub fn create_invoice_builder_from_invoice_request_with_keys<'a, R: Router, F>(
&self, router: &R, invoice_request: &'a VerifiedInvoiceRequest<DerivedSigningPubkey>,
usable_channels: Vec<ChannelDetails>, get_payment_info: F,
+ payment_metadata: Option<BTreeMap<u64, Vec<u8>>>,
) -> Result<(InvoiceBuilder<'a, DerivedSigningPubkey>, MessageContext), Bolt12SemanticError>
where
F: Fn(u64, u32) -> Result<(PaymentHash, PaymentSecret), Bolt12SemanticError>,
@@ -977,6 +984,7 @@ impl<MR: MessageRouter, L: Logger> OffersMessageFlow<MR, L> {
let context = PaymentContext::Bolt12Offer(Bolt12OfferContext {
offer_id: invoice_request.offer_id,
invoice_request: invoice_request.fields(),
+ payment_metadata,
});
let payment_paths = self
@@ -1022,6 +1030,7 @@ impl<MR: MessageRouter, L: Logger> OffersMessageFlow<MR, L> {
pub fn create_invoice_builder_from_invoice_request_without_keys<'a, R: Router, F>(
&self, router: &R, invoice_request: &'a VerifiedInvoiceRequest<ExplicitSigningPubkey>,
usable_channels: Vec<ChannelDetails>, get_payment_info: F,
+ payment_metadata: Option<BTreeMap<u64, Vec<u8>>>,
) -> Result<(InvoiceBuilder<'a, ExplicitSigningPubkey>, MessageContext), Bolt12SemanticError>
where
F: Fn(u64, u32) -> Result<(PaymentHash, PaymentSecret), Bolt12SemanticError>,
@@ -1036,6 +1045,7 @@ impl<MR: MessageRouter, L: Logger> OffersMessageFlow<MR, L> {
let context = PaymentContext::Bolt12Offer(Bolt12OfferContext {
offer_id: invoice_request.offer_id,
invoice_request: invoice_request.fields(),
+ payment_metadata,
});
let payment_paths = self
@@ -1643,6 +1653,7 @@ impl<MR: MessageRouter, L: Logger> OffersMessageFlow<MR, L> {
offer_relative_expiry,
usable_channels,
peers.clone(),
+ None,
)
.and_then(|builder| builder.build_and_sign(secp_ctx))
.map_err(|_| ())?;
diff --git a/lightning/src/routing/router.rs b/lightning/src/routing/router.rs
index edb048c..f7da185 100644
--- a/lightning/src/routing/router.rs
+++ b/lightning/src/routing/router.rs
@@ -283,6 +283,12 @@ pub trait Router {
/// Creates [`BlindedPaymentPath`]s for payment to the `recipient` node. The channels in `first_hops`
/// are assumed to be with the `recipient`'s peers. The payment secret and any constraints are
/// given in `tlvs`. The `local_node_receive_key` is required to authenticate the blinded payment paths.
+ ///
+ /// While payments will fail if most of `tlvs` is modified, modifying
+ /// [`ReceiveTlvs::payment_context`]'s [`PaymentContext::payment_metadata`] fields prior to
+ /// blinded path construction is allowed.
+ ///
+ /// [`PaymentContext::payment_metadata`]: crate::blinded_path::payment::PaymentContext::payment_metadata
fn create_blinded_payment_paths<T: secp256k1::Signing + secp256k1::Verification>(
&self, recipient: PublicKey, local_node_receive_key: ReceiveAuthKey,
first_hops: Vec<ChannelDetails>, tlvs: ReceiveTlvs, amount_msats: Option<u64>,
diff --git a/lightning/src/util/ser.rs b/lightning/src/util/ser.rs
index 4c40382..0f93df2 100644
--- a/lightning/src/util/ser.rs
+++ b/lightning/src/util/ser.rs
@@ -969,6 +969,37 @@ macro_rules! impl_for_map {
impl_for_map!(BTreeMap, Ord, |_| BTreeMap::new());
impl_for_map!(HashMap, Hash, |len| hash_map_with_capacity(len));
+/// A wrapper used to serialize a `BTreeMap<u64, Vec<u8>>` with a few less bytes.
+pub(crate) struct BigSizeKeyedMap<T>(pub T);
+
+impl Writeable for BigSizeKeyedMap<&BTreeMap<u64, Vec<u8>>> {
+ #[inline]
+ fn write<W: Writer>(&self, w: &mut W) -> Result<(), io::Error> {
+ BigSize(self.0.len() as u64).write(w)?;
+ for (key, value) in self.0.iter() {
+ BigSize(*key).write(w)?;
+ value.write(w)?;
+ }
+ Ok(())
+ }
+}
+
+impl LengthReadable for BigSizeKeyedMap<BTreeMap<u64, Vec<u8>>> {
+ #[inline]
+ fn read_from_fixed_length_buffer<R: LengthLimitedRead>(r: &mut R) -> Result<Self, DecodeError> {
+ let len: BigSize = Readable::read(r)?;
+ let mut ret = BTreeMap::new();
+ for _ in 0..len.0 {
+ let key: BigSize = Readable::read(r)?;
+ let value: Vec<u8> = Readable::read(r)?;
+ if ret.insert(key.0, value).is_some() {
+ return Err(DecodeError::InvalidValue);
+ }
+ }
+ Ok(BigSizeKeyedMap(ret))
+ }
+}
+
// HashSet
impl<T> Writeable for HashSet<T>
where
diff --git a/lightning/src/util/test_utils.rs b/lightning/src/util/test_utils.rs
index d7320ff..892c9f4 100644
--- a/lightning/src/util/test_utils.rs
+++ b/lightning/src/util/test_utils.rs
@@ -7,9 +7,11 @@
// You may not use this file except in accordance with one or both of these
// licenses.
+use alloc::collections::BTreeMap;
+
use crate::blinded_path::message::MessageContext;
use crate::blinded_path::message::{BlindedMessagePath, MessageForwardNode};
-use crate::blinded_path::payment::{BlindedPaymentPath, ReceiveTlvs};
+use crate::blinded_path::payment::{BlindedPaymentPath, PaymentContext, ReceiveTlvs};
use crate::chain;
use crate::chain::chaininterface;
#[cfg(any(test, feature = "_externalize_tests"))]
@@ -178,6 +180,7 @@ pub struct TestRouter<'a> {
pub network_graph: Arc<NetworkGraph<&'a TestLogger>>,
pub next_routes: Mutex<VecDeque<(RouteParameters, Option<Result<Route, &'static str>>)>>,
pub next_blinded_payment_paths: Mutex<Vec<BlindedPaymentPath>>,
+ pub next_payment_context_metadata: Mutex<Option<BTreeMap<u64, Vec<u8>>>>,
pub scorer: &'a RwLock<TestScorer>,
}
@@ -189,6 +192,7 @@ impl<'a> TestRouter<'a> {
let entropy_source = Arc::new(RandomBytes::new([42; 32]));
let next_routes = Mutex::new(VecDeque::new());
let next_blinded_payment_paths = Mutex::new(Vec::new());
+ let next_payment_context_metadata = Mutex::new(None);
Self {
router: DefaultRouter::new(
Arc::clone(&network_graph),
@@ -200,10 +204,15 @@ impl<'a> TestRouter<'a> {
network_graph,
next_routes,
next_blinded_payment_paths,
+ next_payment_context_metadata,
scorer,
}
}
+ pub fn set_next_payment_context_metadata(&self, metadata: BTreeMap<u64, Vec<u8>>) {
+ *self.next_payment_context_metadata.lock().unwrap() = Some(metadata);
+ }
+
pub fn expect_find_route(&self, query: RouteParameters, result: Result<Route, &'static str>) {
let mut expected_routes = self.next_routes.lock().unwrap();
expected_routes.push_back((query, Some(result)));
@@ -319,9 +328,16 @@ impl<'a> Router for TestRouter<'a> {
fn create_blinded_payment_paths<T: secp256k1::Signing + secp256k1::Verification>(
&self, recipient: PublicKey, local_node_receive_key: ReceiveAuthKey,
- first_hops: Vec<ChannelDetails>, tlvs: ReceiveTlvs, amount_msats: Option<u64>,
+ first_hops: Vec<ChannelDetails>, mut tlvs: ReceiveTlvs, amount_msats: Option<u64>,
secp_ctx: &Secp256k1<T>,
) -> Result<Vec<BlindedPaymentPath>, ()> {
+ if let Some(metadata) = self.next_payment_context_metadata.lock().unwrap().take() {
+ match &mut tlvs.payment_context {
+ PaymentContext::Bolt12Offer(ctx) => ctx.payment_metadata = Some(metadata),
+ PaymentContext::AsyncBolt12Offer(ctx) => ctx.payment_metadata = Some(metadata),
+ PaymentContext::Bolt12Refund(ctx) => ctx.payment_metadata = Some(metadata),
+ }
+ }
let mut expected_paths = self.next_blinded_payment_paths.lock().unwrap();
if expected_paths.is_empty() {
self.router.create_blinded_payment_paths(
Why this scored 23/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.