Add a `payment_metadata` map in BOLT 12 blinded message path ctxs
What changed, and why it matters
This commit adds a new optional field called payment_metadata to BOLT 12 invoice request contexts in the Lightning Dev Kit. It lets payment recipients attach small pieces of custom data to an offer, which are then copied into the invoice and returned to them when a payment arrives. The change is a feature addition, not a bug fix, and the commit message and code comments explicitly warn that the metadata must stay small or payments could fail to route. There is no indication in the commit that this resolves a security vulnerability.
Treat as a normal feature commit. If reviewing for production use, verify that downstream code enforces size limits on payment_metadata before it reaches the onion, since the commit only documents the risk and does not add explicit length or total-size caps.
Security signals we found
New optional user-controlled byte field added to BOLT 12 path contexts
Metadata is propagated into invoice blinded payment paths and surfaced in PaymentClaimable events
Commit message and comments warn that large metadata can make payments unpayable or QR codes too large
No bounds check on individual value length or total map size is visible in the diff
No public ChannelManager API yet exposed, limiting how callers can set metadata
Evidence from the diff
The patch extends OffersContext::InvoiceRequest with an optional BTreeMap
Changed components
lightning/src/blinded_path/message.rslightning/src/ln/channelmanager.rslightning/src/offers/flow.rslightning/src/onion_message/messenger.rsInspect captured patch +148 / −12
diff --git a/lightning/src/blinded_path/message.rs b/lightning/src/blinded_path/message.rs
index 7bcbe80..bd2b59c 100644
--- a/lightning/src/blinded_path/message.rs
+++ b/lightning/src/blinded_path/message.rs
@@ -9,6 +9,8 @@
//! Data structures and methods for constructing [`BlindedMessagePath`]s to send a message over.
+use alloc::collections::BTreeMap;
+
use bitcoin::secp256k1::{self, PublicKey, Secp256k1, SecretKey};
#[allow(unused_imports)]
@@ -29,7 +31,9 @@ use crate::routing::gossip::{NodeId, ReadOnlyNetworkGraph};
use crate::sign::{EntropySource, NodeSigner, ReceiveAuthKey, Recipient};
use crate::types::payment::PaymentHash;
use crate::util::scid_utils;
-use crate::util::ser::{FixedLengthReader, LengthReadableArgs, Readable, Writeable, Writer};
+use crate::util::ser::{
+ BigSizeKeyedMap, FixedLengthReader, LengthReadableArgs, Readable, Writeable, Writer,
+};
use core::time::Duration;
use core::{cmp, mem};
@@ -391,6 +395,28 @@ pub enum OffersContext {
/// [`InvoiceRequest`]: crate::offers::invoice_request::InvoiceRequest
/// [`Offer`]: crate::offers::offer::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). Further, any data placed here will increase
+ /// the size of the offer which may make it difficult to fit in QR codes.
+ ///
+ /// [`RecipientOnionFields::payment_metadata`]: crate::ln::outbound_payment::RecipientOnionFields::payment_metadata
+ /// [`Bolt11Invoice::payment_metadata`]: lightning_invoice::Bolt11Invoice::payment_metadata
+ payment_metadata: Option<BTreeMap<u64, Vec<u8>>>,
},
/// Context used by a [`BlindedMessagePath`] within the [`Offer`] of an async recipient.
///
@@ -648,6 +674,7 @@ impl_writeable_tlv_based_enum!(MessageContext,
impl_writeable_tlv_based_enum!(OffersContext,
(0, InvoiceRequest) => {
(0, nonce, required),
+ (1, payment_metadata, (option, encoding: (BTreeMap<u64, Vec<u8>>, BigSizeKeyedMap))),
},
(1, OutboundPaymentForRefund) => {
(0, payment_id, required),
diff --git a/lightning/src/ln/async_payments_tests.rs b/lightning/src/ln/async_payments_tests.rs
index 817e130..7bd745d 100644
--- a/lightning/src/ln/async_payments_tests.rs
+++ b/lightning/src/ln/async_payments_tests.rs
@@ -317,7 +317,10 @@ fn create_static_invoice<T: secp256k1::Signing + secp256k1::Verification>(
.create_blinded_paths(
always_online_counterparty.node.get_our_node_id(),
always_online_counterparty.keys_manager.get_receive_auth_key(),
- MessageContext::Offers(OffersContext::InvoiceRequest { nonce: Nonce([42; 16]) }),
+ MessageContext::Offers(OffersContext::InvoiceRequest {
+ nonce: Nonce([42; 16]),
+ payment_metadata: None,
+ }),
Vec::new(),
&secp_ctx,
)
@@ -688,7 +691,10 @@ fn static_invoice_unknown_required_features() {
.create_blinded_paths(
nodes[1].node.get_our_node_id(),
nodes[1].keys_manager.get_receive_auth_key(),
- MessageContext::Offers(OffersContext::InvoiceRequest { nonce: Nonce([42; 16]) }),
+ MessageContext::Offers(OffersContext::InvoiceRequest {
+ nonce: Nonce([42; 16]),
+ payment_metadata: None,
+ }),
Vec::new(),
&secp_ctx,
)
@@ -1755,7 +1761,10 @@ fn invalid_async_receive_with_retry<F1, F2>(
.create_blinded_paths(
nodes[1].node.get_our_node_id(),
nodes[1].keys_manager.get_receive_auth_key(),
- MessageContext::Offers(OffersContext::InvoiceRequest { nonce: Nonce([42; 16]) }),
+ MessageContext::Offers(OffersContext::InvoiceRequest {
+ nonce: Nonce([42; 16]),
+ payment_metadata: None,
+ }),
Vec::new(),
&secp_ctx,
)
diff --git a/lightning/src/ln/channelmanager.rs b/lightning/src/ln/channelmanager.rs
index 9ceae85..ec09235 100644
--- a/lightning/src/ln/channelmanager.rs
+++ b/lightning/src/ln/channelmanager.rs
@@ -17092,6 +17092,13 @@ impl<
None => return None,
};
+ let payment_metadata =
+ if let Some(OffersContext::InvoiceRequest { payment_metadata, .. }) = &context {
+ payment_metadata.clone()
+ } else {
+ None
+ };
+
let invoice_request = match self.flow.verify_invoice_request(invoice_request, context) {
Ok(InvreqResponseInstructions::SendInvoice(invoice_request)) => invoice_request,
Ok(InvreqResponseInstructions::SendStaticInvoice { recipient_id, invoice_slot, invoice_request }) => {
@@ -17119,7 +17126,7 @@ impl<
&request,
self.list_usable_channels(),
get_payment_info,
- None,
+ payment_metadata,
);
match result {
@@ -17144,7 +17151,7 @@ impl<
&request,
self.list_usable_channels(),
get_payment_info,
- None,
+ payment_metadata,
);
match result {
diff --git a/lightning/src/ln/offers_tests.rs b/lightning/src/ln/offers_tests.rs
index d1ec9b4..5eaf64b 100644
--- a/lightning/src/ln/offers_tests.rs
+++ b/lightning/src/ln/offers_tests.rs
@@ -50,7 +50,7 @@ use core::time::Duration;
use crate::blinded_path::IntroductionNode;
use crate::blinded_path::message::BlindedMessagePath;
use crate::blinded_path::payment::{Bolt12OfferContext, Bolt12RefundContext, DummyTlvs, PaymentContext};
-use crate::blinded_path::message::OffersContext;
+use crate::blinded_path::message::{MessageContext, OffersContext};
use crate::events::{ClosureReason, Event, HTLCHandlingFailureType, PaidBolt12Invoice, PaymentFailureReason, PaymentPurpose};
use crate::ln::channelmanager::{PaymentId, RecentPaymentDetails, self};
use crate::ln::outbound_payment::{Bolt12PaymentError, RecipientOnionFields, Retry};
@@ -62,8 +62,9 @@ use crate::offers::invoice::Bolt12Invoice;
use crate::offers::invoice_error::InvoiceError;
use crate::offers::invoice_request::{InvoiceRequest, InvoiceRequestFields, InvoiceRequestVerifiedFromOffer};
use crate::offers::nonce::Nonce;
+use crate::offers::offer::OfferBuilder;
use crate::offers::parse::Bolt12SemanticError;
-use crate::onion_message::messenger::{DefaultMessageRouter, Destination, MessageSendInstructions, NodeIdMessageRouter, NullMessageRouter, PeeledOnion, DUMMY_HOPS_PATH_LENGTH, QR_CODED_DUMMY_HOPS_PATH_LENGTH};
+use crate::onion_message::messenger::{DefaultMessageRouter, Destination, MessageRouter, MessageSendInstructions, NodeIdMessageRouter, NullMessageRouter, PeeledOnion, DUMMY_HOPS_PATH_LENGTH, QR_CODED_DUMMY_HOPS_PATH_LENGTH};
use crate::onion_message::offers::OffersMessage;
use crate::routing::gossip::{NodeAlias, NodeId};
use crate::routing::router::{DEFAULT_PAYMENT_DUMMY_HOPS, PaymentParameters, RouteParameters, RouteParametersConfig};
@@ -258,7 +259,7 @@ fn claim_bolt12_payment_with_extra_fees<'a, 'b, 'c>(
fn extract_offer_nonce<'a, 'b, 'c>(node: &Node<'a, 'b, 'c>, message: &OnionMessage) -> Nonce {
match node.onion_messenger.peel_onion_message(message) {
- Ok(PeeledOnion::Offers(_, Some(OffersContext::InvoiceRequest { nonce }), _)) => nonce,
+ Ok(PeeledOnion::Offers(_, Some(OffersContext::InvoiceRequest { nonce, payment_metadata: _ }), _)) => nonce,
Ok(PeeledOnion::Offers(_, context, _)) => panic!("Unexpected onion message context: {:?}", context),
Ok(PeeledOnion::Forward(_, _)) => panic!("Unexpected onion message forward"),
Ok(_) => panic!("Unexpected onion message"),
@@ -983,6 +984,89 @@ fn router_modifies_payment_metadata_in_blinded_path() {
expect_recent_payment!(bob, RecentPaymentDetails::Fulfilled, payment_id);
}
+/// Checks that `payment_metadata` set in the [`OffersContext::InvoiceRequest`] of an offer's
+/// blinded message path is propagated to the [`Bolt12OfferContext`] in the resulting invoice's
+/// blinded payment paths and surfaced via [`Event::PaymentClaimable`] when the payment is received.
+#[test]
+fn pays_for_offer_with_payment_metadata_in_invoice_request_context() {
+ 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();
+
+ // Manually build an offer whose blinded message path carries `payment_metadata` in its
+ // `OffersContext::InvoiceRequest` context. The HEAD commit causes Alice's `ChannelManager` to
+ // copy this metadata onto the `Bolt12OfferContext` when she handles the inbound invoice
+ // request, embedding it in the invoice's blinded payment paths.
+ let mut expected_metadata = BTreeMap::new();
+ expected_metadata.insert(0u64, vec![1, 2, 3, 4]);
+ expected_metadata.insert(7u64, vec![0xab, 0xcd]);
+
+ let secp_ctx = Secp256k1::new();
+ let nonce = Nonce::from_entropy_source(alice.keys_manager);
+ let context = MessageContext::Offers(OffersContext::InvoiceRequest {
+ nonce,
+ payment_metadata: Some(expected_metadata.clone()),
+ });
+ let paths = alice.message_router.create_blinded_paths(
+ alice_id,
+ alice.keys_manager.get_receive_auth_key(),
+ context,
+ alice.node.test_get_peers_for_blinded_path(),
+ &secp_ctx,
+ ).unwrap();
+ assert!(!paths.is_empty());
+
+ let expanded_key = alice.keys_manager.get_expanded_key();
+ let mut builder = OfferBuilder::deriving_signing_pubkey(alice_id, &expanded_key, nonce, &secp_ctx)
+ .chain(Network::Testnet)
+ .amount_msats(10_000_000);
+ for path in paths {
+ builder = builder.path(path);
+ }
+ let offer = builder.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);
+
+ 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);
+
+ 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);
+
+ // `claim_bolt12_payment` asserts the surfaced `PaymentContext` matches `payment_context`
+ // above, including the embedded `payment_metadata`.
+ 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.
diff --git a/lightning/src/offers/flow.rs b/lightning/src/offers/flow.rs
index e3bf66c..bdc3475 100644
--- a/lightning/src/offers/flow.rs
+++ b/lightning/src/offers/flow.rs
@@ -454,7 +454,7 @@ impl<MR: MessageRouter, L: Logger> OffersMessageFlow<MR, L> {
let nonce = match context {
None if invoice_request.metadata().is_some() => None,
- Some(OffersContext::InvoiceRequest { nonce }) => Some(nonce),
+ Some(OffersContext::InvoiceRequest { nonce, payment_metadata: _ }) => Some(nonce),
Some(OffersContext::StaticInvoiceRequested {
recipient_id,
invoice_slot,
@@ -561,7 +561,8 @@ impl<MR: MessageRouter, L: Logger> OffersMessageFlow<MR, L> {
let secp_ctx = &self.secp_ctx;
let nonce = Nonce::from_entropy_source(entropy);
- let context = MessageContext::Offers(OffersContext::InvoiceRequest { nonce });
+ let context =
+ MessageContext::Offers(OffersContext::InvoiceRequest { nonce, payment_metadata: None });
let mut builder =
OfferBuilder::deriving_signing_pubkey(node_id, expanded_key, nonce, secp_ctx)
@@ -1658,7 +1659,10 @@ impl<MR: MessageRouter, L: Logger> OffersMessageFlow<MR, L> {
.and_then(|builder| builder.build_and_sign(secp_ctx))
.map_err(|_| ())?;
- let context = MessageContext::Offers(OffersContext::InvoiceRequest { nonce: offer_nonce });
+ let context = MessageContext::Offers(OffersContext::InvoiceRequest {
+ nonce: offer_nonce,
+ payment_metadata: None,
+ });
let forward_invoice_request_path = self
.create_blinded_paths(peers, context)
.and_then(|paths| paths.into_iter().next().ok_or(()))?;
diff --git a/lightning/src/onion_message/messenger.rs b/lightning/src/onion_message/messenger.rs
index 7ef4e4a..98a54e2 100644
--- a/lightning/src/onion_message/messenger.rs
+++ b/lightning/src/onion_message/messenger.rs
@@ -469,6 +469,11 @@ pub trait MessageRouter {
/// Creates [`BlindedMessagePath`]s to the `recipient` node. The nodes in `peers` are assumed to
/// be direct peers with the `recipient`.
+ ///
+ /// While payments will fail if most of `context` is modified, modifying
+ /// [`OffersContext::InvoiceRequest::payment_metadata`] prior to blinded path construction is
+ /// allowed.
+ ///
fn create_blinded_paths<T: secp256k1::Signing + secp256k1::Verification>(
&self, recipient: PublicKey, local_node_receive_key: ReceiveAuthKey,
context: MessageContext, peers: Vec<MessageForwardNode>, secp_ctx: &Secp256k1<T>,
Why this scored 18/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.