Split `OffersContext::OutboundPayment` into `InRefund`/`InInvReq`
What changed, and why it matters
This commit refactors how the Lightning Dev Kit tracks whether a BOLT12 invoice is tied to a Refund versus a regular InvoiceRequest. Previously, one shared context type was used for both, which could lead to ambiguous handling. The change splits that shared context into two distinct variants and adds checks so that Refund-only contexts are only accepted for Refund invoices and InvoiceRequest contexts only for Offer invoices. It is a hardening/correctness change rather than a clear-cut security fix, but it removes ambiguity that could in principle be abused to trick the code into accepting an invoice in the wrong context.
Treat as a defensive hardening commit. Review the follow-up commit referenced in the message to confirm that behavior changes based on the new context types do not introduce new validation gaps. Ensure downstream serialization compatibility is handled for the renamed enum variant and new tag.
Security signals we found
Removes ambiguous shared context that was used for two different BOLT12 flows
Adds explicit type checks before verifying Bolt12Invoice against payer data
Moves invoice verification logic into a dedicated offers flow module
Prevents an OutboundPaymentForOffer context from being accepted for a Refund invoice and vice versa
Commit message explicitly notes the ambiguity and the desire to avoid it
Evidence from the diff
The commit splits OffersContext::OutboundPayment into OutboundPaymentForRefund and OutboundPaymentForOffer. It moves verify_bolt12_invoice from ChannelManager into the Offers flow module and adds invoice.is_for_offer()/is_for_refund() guards so that each context variant can only verify the matching invoice type. Serialization is updated so the original variant tag (1) now maps to OutboundPaymentForRefund and a new tag (4) is added for OutboundPaymentForOffer. The commit message frames this as a clarity/hardening step in preparation for later behavior changes based on context type.
Changed components
lightning/src/blinded_path/message.rslightning/src/ln/channelmanager.rslightning/src/offers/flow.rslightning/src/offers/invoice.rsInspect captured patch +83 / −37
diff --git a/lightning/src/blinded_path/message.rs b/lightning/src/blinded_path/message.rs
index ed55ca5..8210d2d 100644
--- a/lightning/src/blinded_path/message.rs
+++ b/lightning/src/blinded_path/message.rs
@@ -416,28 +416,45 @@ pub enum OffersContext {
/// Useful to timeout async recipients that are no longer supported as clients.
path_absolute_expiry: Duration,
},
- /// Context used by a [`BlindedMessagePath`] within a [`Refund`] or as a reply path for an
- /// [`InvoiceRequest`].
+ /// Context used by a [`BlindedMessagePath`] within a [`Refund`].
///
/// This variant is intended to be received when handling a [`Bolt12Invoice`] or an
/// [`InvoiceError`].
///
/// [`Refund`]: crate::offers::refund::Refund
- /// [`InvoiceRequest`]: crate::offers::invoice_request::InvoiceRequest
/// [`Bolt12Invoice`]: crate::offers::invoice::Bolt12Invoice
/// [`InvoiceError`]: crate::offers::invoice_error::InvoiceError
- OutboundPayment {
- /// Payment ID used when creating a [`Refund`] or [`InvoiceRequest`].
+ OutboundPaymentForRefund {
+ /// Payment ID used when creating a [`Refund`].
///
/// [`Refund`]: crate::offers::refund::Refund
- /// [`InvoiceRequest`]: crate::offers::invoice_request::InvoiceRequest
payment_id: PaymentId,
- /// A nonce used for authenticating that a [`Bolt12Invoice`] is for a valid [`Refund`] or
- /// [`InvoiceRequest`] and for deriving their signing keys.
+ /// A nonce used for authenticating that a [`Bolt12Invoice`] is for a valid [`Refund`] and
+ /// for deriving its signing keys.
///
/// [`Bolt12Invoice`]: crate::offers::invoice::Bolt12Invoice
/// [`Refund`]: crate::offers::refund::Refund
+ nonce: Nonce,
+ },
+ /// Context used by a [`BlindedMessagePath`] as a reply path for an [`InvoiceRequest`].
+ ///
+ /// This variant is intended to be received when handling a [`Bolt12Invoice`] or an
+ /// [`InvoiceError`].
+ ///
+ /// [`InvoiceRequest`]: crate::offers::invoice_request::InvoiceRequest
+ /// [`Bolt12Invoice`]: crate::offers::invoice::Bolt12Invoice
+ /// [`InvoiceError`]: crate::offers::invoice_error::InvoiceError
+ OutboundPaymentForOffer {
+ /// Payment ID used when creating an [`InvoiceRequest`].
+ ///
+ /// [`InvoiceRequest`]: crate::offers::invoice_request::InvoiceRequest
+ payment_id: PaymentId,
+
+ /// A nonce used for authenticating that a [`Bolt12Invoice`] is for a valid
+ /// [`InvoiceRequest`] and for deriving its signing keys.
+ ///
+ /// [`Bolt12Invoice`]: crate::offers::invoice::Bolt12Invoice
/// [`InvoiceRequest`]: crate::offers::invoice_request::InvoiceRequest
nonce: Nonce,
},
@@ -619,7 +636,7 @@ impl_writeable_tlv_based_enum!(OffersContext,
(0, InvoiceRequest) => {
(0, nonce, required),
},
- (1, OutboundPayment) => {
+ (1, OutboundPaymentForRefund) => {
(0, payment_id, required),
(1, nonce, required),
},
@@ -631,6 +648,10 @@ impl_writeable_tlv_based_enum!(OffersContext,
(2, invoice_slot, required),
(4, path_absolute_expiry, required),
},
+ (4, OutboundPaymentForOffer) => {
+ (0, payment_id, required),
+ (1, nonce, required),
+ },
);
impl_writeable_tlv_based_enum!(AsyncPaymentsContext,
diff --git a/lightning/src/ln/channelmanager.rs b/lightning/src/ln/channelmanager.rs
index 6449205..79a678f 100644
--- a/lightning/src/ln/channelmanager.rs
+++ b/lightning/src/ln/channelmanager.rs
@@ -5593,29 +5593,12 @@ where
pub fn send_payment_for_bolt12_invoice(
&self, invoice: &Bolt12Invoice, context: Option<&OffersContext>,
) -> Result<(), Bolt12PaymentError> {
- match self.verify_bolt12_invoice(invoice, context) {
+ match self.flow.verify_bolt12_invoice(invoice, context) {
Ok(payment_id) => self.send_payment_for_verified_bolt12_invoice(invoice, payment_id),
Err(()) => Err(Bolt12PaymentError::UnexpectedInvoice),
}
}
- fn verify_bolt12_invoice(
- &self, invoice: &Bolt12Invoice, context: Option<&OffersContext>,
- ) -> Result<PaymentId, ()> {
- let secp_ctx = &self.secp_ctx;
- let expanded_key = &self.inbound_payment_key;
-
- match context {
- None if invoice.is_for_refund_without_paths() => {
- invoice.verify_using_metadata(expanded_key, secp_ctx)
- },
- Some(&OffersContext::OutboundPayment { payment_id, nonce, .. }) => {
- invoice.verify_using_payer_data(payment_id, nonce, expanded_key, secp_ctx)
- },
- _ => Err(()),
- }
- }
-
fn send_payment_for_verified_bolt12_invoice(
&self, invoice: &Bolt12Invoice, payment_id: PaymentId,
) -> Result<(), Bolt12PaymentError> {
@@ -15366,7 +15349,7 @@ where
},
OffersMessage::StaticInvoice(invoice) => {
let payment_id = match context {
- Some(OffersContext::OutboundPayment { payment_id, .. }) => payment_id,
+ Some(OffersContext::OutboundPaymentForOffer { payment_id, .. }) => payment_id,
_ => return None
};
let res = self.initiate_async_payment(&invoice, payment_id);
@@ -15382,7 +15365,8 @@ where
log_trace!(logger, "Received invoice_error: {}", invoice_error);
match context {
- Some(OffersContext::OutboundPayment { payment_id, .. }) => {
+ Some(OffersContext::OutboundPaymentForOffer { payment_id, .. })
+ |Some(OffersContext::OutboundPaymentForRefund { payment_id, .. }) => {
self.abandon_payment_with_reason(
payment_id, PaymentFailureReason::InvoiceRequestRejected,
);
diff --git a/lightning/src/offers/flow.rs b/lightning/src/offers/flow.rs
index 88f0cc5..05e488f 100644
--- a/lightning/src/offers/flow.rs
+++ b/lightning/src/offers/flow.rs
@@ -495,11 +495,12 @@ where
Ok(InvreqResponseInstructions::SendInvoice(invoice_request))
}
- /// Verifies a [`Bolt12Invoice`] using the provided [`OffersContext`] or the invoice's payer metadata,
- /// returning the corresponding [`PaymentId`] if successful.
+ /// Verifies a [`Bolt12Invoice`] using the provided [`OffersContext`] or the invoice's payer
+ /// metadata, returning the corresponding [`PaymentId`] if successful.
///
- /// - If an [`OffersContext::OutboundPayment`] with a `nonce` is provided, verification is performed
- /// using this to form the payer metadata.
+ /// - If an [`OffersContext::OutboundPaymentForOffer`] or
+ /// [`OffersContext::OutboundPaymentForRefund`] with a `nonce` is provided, verification is
+ /// performed using this to form the payer metadata.
/// - If no context is provided and the invoice corresponds to a [`Refund`] without blinded paths,
/// verification is performed using the [`Bolt12Invoice::payer_metadata`].
/// - If neither condition is met, verification fails.
@@ -513,8 +514,19 @@ where
None if invoice.is_for_refund_without_paths() => {
invoice.verify_using_metadata(expanded_key, secp_ctx)
},
- Some(&OffersContext::OutboundPayment { payment_id, nonce, .. }) => {
- invoice.verify_using_payer_data(payment_id, nonce, expanded_key, secp_ctx)
+ Some(&OffersContext::OutboundPaymentForOffer { payment_id, nonce, .. }) => {
+ if invoice.is_for_offer() {
+ invoice.verify_using_payer_data(payment_id, nonce, expanded_key, secp_ctx)
+ } else {
+ Err(())
+ }
+ },
+ Some(&OffersContext::OutboundPaymentForRefund { payment_id, nonce, .. }) => {
+ if invoice.is_for_refund() {
+ invoice.verify_using_payer_data(payment_id, nonce, expanded_key, secp_ctx)
+ } else {
+ Err(())
+ }
},
_ => Err(()),
}
@@ -680,7 +692,8 @@ where
let secp_ctx = &self.secp_ctx;
let nonce = Nonce::from_entropy_source(entropy);
- let context = MessageContext::Offers(OffersContext::OutboundPayment { payment_id, nonce });
+ let context =
+ MessageContext::Offers(OffersContext::OutboundPaymentForRefund { payment_id, nonce });
// Create the base builder with common properties
let mut builder = RefundBuilder::deriving_signing_pubkey(
@@ -1116,7 +1129,8 @@ where
&self, invoice_request: InvoiceRequest, payment_id: PaymentId, nonce: Nonce,
peers: Vec<MessageForwardNode>,
) -> Result<(), Bolt12SemanticError> {
- let context = MessageContext::Offers(OffersContext::OutboundPayment { payment_id, nonce });
+ let context =
+ MessageContext::Offers(OffersContext::OutboundPaymentForOffer { payment_id, nonce });
let reply_paths = self
.create_blinded_paths(peers, context)
.map_err(|_| Bolt12SemanticError::MissingPaths)?;
diff --git a/lightning/src/offers/invoice.rs b/lightning/src/offers/invoice.rs
index 6dfd6ea..8d83225 100644
--- a/lightning/src/offers/invoice.rs
+++ b/lightning/src/offers/invoice.rs
@@ -778,6 +778,19 @@ struct InvoiceFields {
}
macro_rules! invoice_accessors { ($self: ident, $contents: expr) => {
+ /// Whether the invoice was created in response to a [`Refund`].
+ pub fn is_for_refund(&$self) -> bool {
+ $contents.is_for_refund()
+ }
+
+ /// Whether the invoice was created in response to an [`InvoiceRequest`] created from an
+ /// [`Offer`].
+ ///
+ /// [`Offer`]: crate::offers::offer::Offer
+ pub fn is_for_offer(&$self) -> bool {
+ $contents.is_for_offer()
+ }
+
/// The chains that may be used when paying a requested invoice.
///
/// From [`Offer::chains`]; `None` if the invoice was created in response to a [`Refund`].
@@ -1093,6 +1106,20 @@ impl InvoiceContents {
}
}
+ fn is_for_refund(&self) -> bool {
+ match self {
+ InvoiceContents::ForRefund { .. } => true,
+ InvoiceContents::ForOffer { .. } => false,
+ }
+ }
+
+ fn is_for_offer(&self) -> bool {
+ match self {
+ InvoiceContents::ForRefund { .. } => false,
+ InvoiceContents::ForOffer { .. } => true,
+ }
+ }
+
fn offer_chains(&self) -> Option<Vec<ChainHash>> {
match self {
InvoiceContents::ForOffer { invoice_request, .. } => {
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.