From cac9f48025aeeabe39047c803f2f1784fcba40b7 Mon Sep 17 00:00:00 2001 From: Vincenzo Palazzo Date: Wed, 10 Jun 2026 18:59:17 +0200 Subject: [PATCH 1/6] Add payment_notifications feature bit for bLIP 56 bLIP 56 defines a protocol for point-of-sale devices to receive payment notifications from merchants over onion messages. The merchant advertises support by setting the payment_notifications feature bit on the offer templates it hands to point-of-sale devices, and the bit remains set on each per-order offer derived from a template. The bit assignment (264/265) is provisional until the bLIP is merged. Co-Authored-By: Claude Fable 5 --- lightning-types/src/features.rs | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/lightning-types/src/features.rs b/lightning-types/src/features.rs index 21d59b2b917..02fcc0ff64b 100644 --- a/lightning-types/src/features.rs +++ b/lightning-types/src/features.rs @@ -83,6 +83,8 @@ //! (see [BOLT PR #1160](https://github.com/lightning/bolts/pull/1160) for more information). //! - `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). +//! - `PaymentNotifications` - supports delivering payment notifications for point-of-sale offers +//! (see [bLIP 56](https://github.com/lightning/blips/pull/56) 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 @@ -221,7 +223,12 @@ mod sealed { // Byte 7 Trampoline, ]); - define_context!(OfferContext, []); + define_context!(OfferContext, [ + // Byte 0 - 32 + ,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,, + // Byte 33 + PaymentNotifications, + ]); define_context!(InvoiceRequestContext, []); define_context!(Bolt12InvoiceContext, [ // Byte 0 @@ -728,6 +735,17 @@ mod sealed { supports_dns_resolution, requires_dns_resolution ); + define_feature!( + 265, + PaymentNotifications, + [OfferContext], + "Feature flags for point-of-sale payment notifications.", + set_payment_notifications_optional, + set_payment_notifications_required, + clear_payment_notifications, + supports_payment_notifications, + requires_payment_notifications + ); // Note: update the module-level docs when a new feature bit is added! From b281ec27e4f3de983337da8f7fef61526195d373 Mon Sep 17 00:00:00 2001 From: Vincenzo Palazzo Date: Wed, 10 Jun 2026 18:59:33 +0200 Subject: [PATCH 2/6] offers: Add encrypted payment tokens for point-of-sale offers Per bLIP 56, a point-of-sale device seals the order fields (amount, description, and a per-order secret order_id) into a payment_token encrypted to the offer's signing pubkey, which only the merchant can recover the secret key for. The merchant verifies the sealed amount and description against the mirrored offer at invoice-request time and relays order_id in its payment notification, which is what authenticates the notification to the device. The token uses a fresh ephemeral key per order, so ciphertexts cannot be linked to the merchant key across offers, with ChaCha20Poly1305 keyed by the ECDH shared secret providing authenticated encryption. Co-Authored-By: Claude Fable 5 --- lightning/src/offers/mod.rs | 1 + lightning/src/offers/payment_token.rs | 212 ++++++++++++++++++++++++++ 2 files changed, 213 insertions(+) create mode 100644 lightning/src/offers/payment_token.rs diff --git a/lightning/src/offers/mod.rs b/lightning/src/offers/mod.rs index 5b5cf6cdc78..c7473f93a8c 100644 --- a/lightning/src/offers/mod.rs +++ b/lightning/src/offers/mod.rs @@ -25,6 +25,7 @@ pub mod merkle; pub mod nonce; pub mod parse; mod payer; +pub mod payment_token; pub mod refund; pub(crate) mod signer; pub mod static_invoice; diff --git a/lightning/src/offers/payment_token.rs b/lightning/src/offers/payment_token.rs new file mode 100644 index 00000000000..5939790342b --- /dev/null +++ b/lightning/src/offers/payment_token.rs @@ -0,0 +1,212 @@ +// This file is Copyright its original authors, visible in version control +// history. +// +// This file is licensed under the Apache License, Version 2.0 or the MIT license +// , at your option. +// You may not use this file except in accordance with one or both of these +// licenses. + +//! Encrypted payment tokens for point-of-sale offers. +//! +//! A point-of-sale (PoS) device constructing a per-order offer from a merchant-provided template +//! seals the order fields -- amount, description, and a per-order secret `order_id` -- into a +//! `payment_token` encrypted to the offer's signing pubkey. Only the merchant can decrypt the +//! token. At invoice-request time the merchant verifies the token's amount and description against +//! the mirrored offer fields, and at payment time it relays the `order_id` in its payment +//! notification, which is what authenticates the notification to the PoS. +//! +//! The token is encrypted using an ephemeral key per order, so token ciphertexts cannot be linked +//! to the merchant key across offers. +//! +//! See [bLIP 56](https://github.com/lightning/blips/pull/56) for more information. + +use bitcoin::secp256k1::ecdh::SharedSecret; +use bitcoin::secp256k1::{self, PublicKey, Secp256k1, SecretKey}; + +use crate::crypto::streams::{ChaChaPolyReadAdapter, ChaChaPolyWriteAdapter}; +use crate::io::Cursor; +use crate::prelude::*; +use crate::sign::EntropySource; +use crate::util::ser::{FixedLengthReader, LengthReadableArgs, Writeable}; + +use core::ops::Deref; + +/// The minimum length in bytes of a payment token's `order_id`. +/// +/// The `order_id` authenticates payment notifications, so it must carry enough entropy that a +/// customer holding the offer cannot guess it. +pub const MIN_ORDER_ID_LEN: usize = 16; + +/// The length in bytes of the ephemeral public key prefixed to an encrypted payment token. +const EPHEMERAL_PUBKEY_LEN: usize = 33; + +/// The length in bytes of the Poly1305 authentication tag appended to an encrypted payment token. +const TAG_LEN: usize = 16; + +/// The order fields sealed into an offer's `payment_token`. +/// +/// The point-of-sale device commits to these fields when constructing a per-order offer. The +/// merchant decrypts them at invoice-request time and must reject the request if `amount_msats` +/// or `description` do not match the mirrored offer fields. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct PaymentTokenPayload { + /// The amount in millisatoshis set on the per-order offer. + pub amount_msats: u64, + /// The description set on the per-order offer, if any. + pub description: Option, + /// The per-order secret generated by the point-of-sale device, relayed by the merchant in its + /// payment notification. Must be at least [`MIN_ORDER_ID_LEN`] bytes. + pub order_id: Vec, +} + +impl_ser_tlv_based!(PaymentTokenPayload, { + (0, amount_msats, required), + (2, description, option), + (4, order_id, required_vec), +}); + +impl PaymentTokenPayload { + /// Encrypts the payload to the given public key, producing an opaque payment token. + /// + /// The token format is `ephemeral_pubkey (33 bytes) || ciphertext || tag (16 bytes)`. A fresh + /// ephemeral key is drawn from `entropy_source` for each token so that ciphertexts cannot be + /// linked to `recipient_pubkey` across offers. + pub fn encrypt( + &self, recipient_pubkey: &PublicKey, entropy_source: ES, secp_ctx: &Secp256k1, + ) -> Vec + where + ES::Target: EntropySource, + { + let ephemeral_secret_bytes = entropy_source.get_secure_random_bytes(); + let ephemeral_secret = + SecretKey::from_slice(&ephemeral_secret_bytes).expect("Valid secret key"); + let ephemeral_pubkey = PublicKey::from_secret_key(secp_ctx, &ephemeral_secret); + let rho = SharedSecret::new(recipient_pubkey, &ephemeral_secret).secret_bytes(); + + let write_adapter = ChaChaPolyWriteAdapter::new(rho, self); + let mut token = + Vec::with_capacity(EPHEMERAL_PUBKEY_LEN + write_adapter.serialized_length()); + token.extend_from_slice(&ephemeral_pubkey.serialize()); + write_adapter.write(&mut token).expect("writing to a Vec never fails"); + token + } + + /// Decrypts a payment token using the secret key corresponding to the public key it was + /// encrypted to. + /// + /// Errors if the token is malformed, fails to authenticate, or contains an `order_id` shorter + /// than [`MIN_ORDER_ID_LEN`] bytes. + pub fn decrypt(recipient_secret_key: &SecretKey, token: &[u8]) -> Result { + if token.len() < EPHEMERAL_PUBKEY_LEN + TAG_LEN { + return Err(()); + } + + let ephemeral_pubkey = + PublicKey::from_slice(&token[..EPHEMERAL_PUBKEY_LEN]).map_err(|_| ())?; + let rho = SharedSecret::new(&ephemeral_pubkey, recipient_secret_key).secret_bytes(); + + let encrypted_payload = &token[EPHEMERAL_PUBKEY_LEN..]; + let mut cursor = Cursor::new(encrypted_payload); + let mut reader = FixedLengthReader::new(&mut cursor, encrypted_payload.len() as u64); + let payload: PaymentTokenPayload = + match ChaChaPolyReadAdapter::::read(&mut reader, rho) { + Ok(ChaChaPolyReadAdapter { readable }) => readable, + Err(_) => return Err(()), + }; + + if payload.order_id.len() < MIN_ORDER_ID_LEN { + return Err(()); + } + + Ok(payload) + } +} + +#[cfg(test)] +mod tests { + use super::{PaymentTokenPayload, MIN_ORDER_ID_LEN}; + use crate::prelude::*; + use crate::util::test_utils::TestKeysInterface; + use bitcoin::network::Network; + use bitcoin::secp256k1::{PublicKey, Secp256k1, SecretKey}; + + fn payload() -> PaymentTokenPayload { + PaymentTokenPayload { + amount_msats: 50_000, + description: Some("coffee, large".to_string()), + order_id: vec![42; MIN_ORDER_ID_LEN], + } + } + + #[test] + fn encrypts_and_decrypts_payment_token() { + let secp_ctx = Secp256k1::new(); + let entropy = TestKeysInterface::new(&[41; 32], Network::Testnet); + let recipient_secret_key = SecretKey::from_slice(&[42; 32]).unwrap(); + let recipient_pubkey = PublicKey::from_secret_key(&secp_ctx, &recipient_secret_key); + + let token = payload().encrypt(&recipient_pubkey, &entropy, &secp_ctx); + let decrypted = PaymentTokenPayload::decrypt(&recipient_secret_key, &token).unwrap(); + assert_eq!(decrypted, payload()); + } + + #[test] + fn fails_decrypting_with_wrong_key() { + let secp_ctx = Secp256k1::new(); + let entropy = TestKeysInterface::new(&[41; 32], Network::Testnet); + let recipient_secret_key = SecretKey::from_slice(&[42; 32]).unwrap(); + let recipient_pubkey = PublicKey::from_secret_key(&secp_ctx, &recipient_secret_key); + + let token = payload().encrypt(&recipient_pubkey, &entropy, &secp_ctx); + let wrong_key = SecretKey::from_slice(&[43; 32]).unwrap(); + assert!(PaymentTokenPayload::decrypt(&wrong_key, &token).is_err()); + } + + #[test] + fn fails_decrypting_tampered_token() { + let secp_ctx = Secp256k1::new(); + let entropy = TestKeysInterface::new(&[41; 32], Network::Testnet); + let recipient_secret_key = SecretKey::from_slice(&[42; 32]).unwrap(); + let recipient_pubkey = PublicKey::from_secret_key(&secp_ctx, &recipient_secret_key); + + let mut token = payload().encrypt(&recipient_pubkey, &entropy, &secp_ctx); + let len = token.len(); + token[len - 20] ^= 1; + assert!(PaymentTokenPayload::decrypt(&recipient_secret_key, &token).is_err()); + } + + #[test] + fn fails_decrypting_truncated_token() { + let recipient_secret_key = SecretKey::from_slice(&[42; 32]).unwrap(); + assert!(PaymentTokenPayload::decrypt(&recipient_secret_key, &[0; 48]).is_err()); + } + + #[test] + fn fails_decrypting_token_with_short_order_id() { + let secp_ctx = Secp256k1::new(); + let entropy = TestKeysInterface::new(&[41; 32], Network::Testnet); + let recipient_secret_key = SecretKey::from_slice(&[42; 32]).unwrap(); + let recipient_pubkey = PublicKey::from_secret_key(&secp_ctx, &recipient_secret_key); + + let short_payload = PaymentTokenPayload { + amount_msats: 50_000, + description: None, + order_id: vec![42; MIN_ORDER_ID_LEN - 1], + }; + let token = short_payload.encrypt(&recipient_pubkey, &entropy, &secp_ctx); + assert!(PaymentTokenPayload::decrypt(&recipient_secret_key, &token).is_err()); + } + + #[test] + fn tokens_for_same_payload_are_unlinkable() { + let secp_ctx = Secp256k1::new(); + let entropy = TestKeysInterface::new(&[41; 32], Network::Testnet); + let recipient_secret_key = SecretKey::from_slice(&[42; 32]).unwrap(); + let recipient_pubkey = PublicKey::from_secret_key(&secp_ctx, &recipient_secret_key); + + let token_a = payload().encrypt(&recipient_pubkey, &entropy, &secp_ctx); + let token_b = payload().encrypt(&recipient_pubkey, &entropy, &secp_ctx); + assert_ne!(token_a, token_b); + } +} From 70efd4e921489ca474d46d3a981c9cd819d81e93 Mon Sep 17 00:00:00 2001 From: Vincenzo Palazzo Date: Wed, 10 Jun 2026 18:59:33 +0200 Subject: [PATCH 3/6] offers: Add bLIP 56 offer records and per-order offer construction Adds the notification_path and payment_token offer records as odd experimental TLVs (1000005601 and 1000005603, provisional) so that wallets unaware of them still accept the offer and mirror the records into the invoice_request, and an OfferModifier allowing a point-of-sale device to construct a per-order offer from a merchant template by filling in the amount, description, expiry, notification paths, and an encrypted payment token. Since the device modifies the offer's records, the merchant cannot authenticate corresponding invoice requests via offer metadata, which covers the entire TLV stream. Instead, a template's signing keys are derived from a nonce alone and matched against the mirrored offer's signing pubkey when verifying an invoice request for delegation. Co-Authored-By: Claude Fable 5 --- lightning/src/offers/invoice.rs | 16 +- lightning/src/offers/invoice_request.rs | 52 ++++++- lightning/src/offers/offer.rs | 196 +++++++++++++++++++++++- lightning/src/offers/parse.rs | 4 + lightning/src/offers/refund.rs | 12 +- lightning/src/offers/signer.rs | 26 ++++ lightning/src/offers/static_invoice.rs | 10 +- 7 files changed, 299 insertions(+), 17 deletions(-) diff --git a/lightning/src/offers/invoice.rs b/lightning/src/offers/invoice.rs index fd77595ca7d..8e24d9319e4 100644 --- a/lightning/src/offers/invoice.rs +++ b/lightning/src/offers/invoice.rs @@ -1572,7 +1572,7 @@ type FullInvoiceTlvStreamRef<'a> = ( InvoiceRequestTlvStreamRef<'a>, InvoiceTlvStreamRef<'a>, SignatureTlvStreamRef<'a>, - ExperimentalOfferTlvStreamRef, + ExperimentalOfferTlvStreamRef<'a>, ExperimentalInvoiceRequestTlvStreamRef, ExperimentalInvoiceTlvStreamRef, ); @@ -1616,7 +1616,7 @@ type PartialInvoiceTlvStreamRef<'a> = ( OfferTlvStreamRef<'a>, InvoiceRequestTlvStreamRef<'a>, InvoiceTlvStreamRef<'a>, - ExperimentalOfferTlvStreamRef, + ExperimentalOfferTlvStreamRef<'a>, ExperimentalInvoiceRequestTlvStreamRef, ExperimentalInvoiceTlvStreamRef, ); @@ -2040,7 +2040,11 @@ mod tests { held_htlc_available_paths: None, }, SignatureTlvStreamRef { signature: Some(&invoice.signature()) }, - ExperimentalOfferTlvStreamRef { experimental_foo: None }, + ExperimentalOfferTlvStreamRef { + notification_paths: None, + payment_token: None, + experimental_foo: None, + }, ExperimentalInvoiceRequestTlvStreamRef { experimental_bar: None }, ExperimentalInvoiceTlvStreamRef { experimental_baz: None }, ), @@ -2143,7 +2147,11 @@ mod tests { held_htlc_available_paths: None, }, SignatureTlvStreamRef { signature: Some(&invoice.signature()) }, - ExperimentalOfferTlvStreamRef { experimental_foo: None }, + ExperimentalOfferTlvStreamRef { + notification_paths: None, + payment_token: None, + experimental_foo: None, + }, ExperimentalInvoiceRequestTlvStreamRef { experimental_bar: None }, ExperimentalInvoiceTlvStreamRef { experimental_baz: None }, ), diff --git a/lightning/src/offers/invoice_request.rs b/lightning/src/offers/invoice_request.rs index 7805882ef73..68d34b52cac 100644 --- a/lightning/src/offers/invoice_request.rs +++ b/lightning/src/offers/invoice_request.rs @@ -82,7 +82,7 @@ use crate::offers::offer::{ }; use crate::offers::parse::{Bolt12ParseError, Bolt12SemanticError, ParsedMessage}; use crate::offers::payer::{PayerContents, PayerTlvStream, PayerTlvStreamRef}; -use crate::offers::signer::{Metadata, MetadataMaterial}; +use crate::offers::signer::{self, Metadata, MetadataMaterial}; use crate::onion_message::dns_resolution::HumanReadableName; use crate::types::features::InvoiceRequestFeatures; use crate::types::payment::PaymentHash; @@ -877,6 +877,46 @@ macro_rules! invoice_request_verify_method { Ok(verified) } +/// Verifies that the request was for a point-of-sale offer template created using the given + /// key, by re-deriving the template's signing keys from the nonce included with the + /// [`BlindedMessagePath`] for which the request was sent through and matching them against + /// the offer's signing pubkey. + /// + /// Unlike [`Self::verify_using_recipient_data`], the offer's TLV records are not covered by + /// the verification, since a point-of-sale device modifies them when constructing per-order + /// offers from the template. The nonce is authenticated by the blinded path itself. + /// + /// [`BlindedMessagePath`]: crate::blinded_path::message::BlindedMessagePath + #[rustfmt::skip] + pub fn verify_for_delegation( + $self: $self_type, nonce: Nonce, key: &ExpandedKey, + ) -> Result { + let signing_pubkey = match $self.contents.inner.offer.issuer_signing_pubkey() { + Some(pubkey) => pubkey, + None => return Err(()), + }; + + let keys = signer::derive_keys_for_delegation(nonce, key); + if keys.public_key() != signing_pubkey { + return Err(()); + } + + let offer_id = OfferId::from_valid_bolt12_tlv_stream(&$self.bytes); + + let inner = { + #[cfg(not(c_bindings))] + { $self } + #[cfg(c_bindings)] + { $self.clone() } + }; + + Ok(InvoiceRequestVerifiedFromOffer::DerivedKeys(VerifiedInvoiceRequest { + offer_id, + inner, + keys: DerivedSigningPubkey(keys), + })) + } + /// Verifies that the request was for an offer created using the given key by checking a nonce /// included with the [`BlindedMessagePath`] for which the request was sent through. /// @@ -1321,7 +1361,7 @@ type FullInvoiceRequestTlvStreamRef<'a> = ( OfferTlvStreamRef<'a>, InvoiceRequestTlvStreamRef<'a>, SignatureTlvStreamRef<'a>, - ExperimentalOfferTlvStreamRef, + ExperimentalOfferTlvStreamRef<'a>, ExperimentalInvoiceRequestTlvStreamRef, ); @@ -1357,7 +1397,7 @@ type PartialInvoiceRequestTlvStreamRef<'a> = ( PayerTlvStreamRef<'a>, OfferTlvStreamRef<'a>, InvoiceRequestTlvStreamRef<'a>, - ExperimentalOfferTlvStreamRef, + ExperimentalOfferTlvStreamRef<'a>, ExperimentalInvoiceRequestTlvStreamRef, ); @@ -1659,7 +1699,11 @@ mod tests { offer_from_hrn: None, }, SignatureTlvStreamRef { signature: Some(&invoice_request.signature()) }, - ExperimentalOfferTlvStreamRef { experimental_foo: None }, + ExperimentalOfferTlvStreamRef { + notification_paths: None, + payment_token: None, + experimental_foo: None, + }, ExperimentalInvoiceRequestTlvStreamRef { experimental_bar: None }, ), ); diff --git a/lightning/src/offers/offer.rs b/lightning/src/offers/offer.rs index b2703454169..641099db7c9 100644 --- a/lightning/src/offers/offer.rs +++ b/lightning/src/offers/offer.rs @@ -84,9 +84,13 @@ use crate::ln::inbound_payment::{ExpandedKey, IV_LEN}; use crate::ln::msgs::{DecodeError, MAX_VALUE_MSAT}; use crate::offers::merkle::{TaggedHash, TlvRecord, TlvStream}; use crate::offers::nonce::Nonce; +use core::ops::Deref; + use crate::offers::parse::{Bech32Encode, Bolt12ParseError, Bolt12SemanticError, ParsedMessage}; +use crate::offers::payment_token::PaymentTokenPayload; use crate::offers::signer::{self, Metadata, MetadataMaterial}; use crate::onion_message::dns_resolution::HumanReadableName; +use crate::sign::EntropySource; use crate::types::features::OfferFeatures; use crate::types::string::PrintableString; use crate::util::ser::{ @@ -247,6 +251,8 @@ macro_rules! offer_explicit_metadata_builder_methods { paths: None, supported_quantity: Quantity::One, issuer_signing_pubkey: Some(signing_pubkey), + notification_paths: None, + payment_token: None, #[cfg(test)] experimental_foo: None, }, @@ -301,6 +307,8 @@ macro_rules! offer_derived_metadata_builder_methods { paths: None, supported_quantity: Quantity::One, issuer_signing_pubkey: Some(node_id), + notification_paths: None, + payment_token: None, #[cfg(test)] experimental_foo: None, }, @@ -363,6 +371,16 @@ macro_rules! offer_builder_methods { ( $return_value } + /// Sets the `payment_notifications` feature bit on [`Offer::offer_features`], advertising + /// that a payment notification will be delivered to the point-of-sale device that constructed + /// a per-order offer from this offer when its payment is claimed. + /// + /// See [bLIP 56](https://github.com/lightning/blips/pull/56) for more information. + pub fn supports_payment_notifications($($self_mut)* $self: $self_type) -> $return_type { + $self.offer.features.set_payment_notifications_optional(); + $return_value + } + /// Sets the [`Offer::description`]. /// /// Successive calls to this method will override the previous setting. @@ -632,6 +650,8 @@ pub(super) struct OfferContents { paths: Option>, supported_quantity: Quantity, issuer_signing_pubkey: Option, + notification_paths: Option>, + payment_token: Option>, #[cfg(test)] experimental_foo: Option, } @@ -708,6 +728,22 @@ macro_rules! offer_accessors { ($self: ident, $contents: expr) => { pub fn issuer_signing_pubkey(&$self) -> Option { $contents.issuer_signing_pubkey() } + + /// Blinded onion message paths to the point-of-sale device that constructed the offer, used by + /// the recipient to deliver a payment notification once the payment is claimed. + /// + /// See [bLIP 56](https://github.com/lightning/blips/pull/56) for more information. + pub fn notification_paths(&$self) -> &[$crate::blinded_path::message::BlindedMessagePath] { + $contents.notification_paths() + } + + /// An opaque payment token sealing the order fields to the offer recipient, used to + /// authenticate payment notifications for point-of-sale offers. + /// + /// See [bLIP 56](https://github.com/lightning/blips/pull/56) for more information. + pub fn payment_token(&$self) -> Option<&Vec> { + $contents.payment_token() + } } } impl Offer { @@ -750,6 +786,43 @@ impl Offer { self.contents.expects_quantity() } + /// Creates an [`OfferModifier`] for constructing a per-order offer from this offer, which + /// must be a point-of-sale offer template advertising the `payment_notifications` feature. + /// + /// This is intended for a point-of-sale device that received the template from a merchant and + /// fills in the order amount, description, expiry, `notification_path`s, and an encrypted + /// `payment_token` before presenting the offer to a customer. Offers are not signed, so the + /// modifications do not invalidate the offer; the merchant verifies a resulting + /// `invoice_request` by re-deriving the template's signing keys from the blinded path it + /// arrived over rather than from the offer's records. + /// + /// Errors if the template does not set the `payment_notifications` feature bit or if any of + /// the fields to be filled in are already set. + /// + /// See [bLIP 56](https://github.com/lightning/blips/pull/56) for more information. + pub fn modify(&self) -> Result { + if !self.contents.features.supports_payment_notifications() { + return Err(Bolt12SemanticError::UnexpectedFeatures); + } + if self.contents.amount.is_some() { + return Err(Bolt12SemanticError::UnexpectedAmount); + } + if self.contents.description.is_some() { + return Err(Bolt12SemanticError::UnexpectedDescription); + } + if self.contents.notification_paths.is_some() { + return Err(Bolt12SemanticError::UnexpectedPaths); + } + if self.contents.payment_token.is_some() { + return Err(Bolt12SemanticError::UnexpectedPaymentToken); + } + if self.contents.issuer_signing_pubkey.is_none() { + return Err(Bolt12SemanticError::MissingIssuerSigningPubkey); + } + + Ok(OfferModifier { contents: self.contents.clone() }) + } + pub(super) fn tlv_stream_iter<'a>( bytes: &'a [u8], ) -> impl core::iter::Iterator> { @@ -875,6 +948,83 @@ impl Hash for Offer { } } +/// Constructs a per-order offer from a point-of-sale offer template, created via +/// [`Offer::modify`]. +/// +/// Unknown TLV records present in the original offer are not preserved by the rebuilt offer. +/// +/// See [bLIP 56](https://github.com/lightning/blips/pull/56) for more information. +#[derive(Clone, Debug)] +pub struct OfferModifier { + contents: OfferContents, +} + +impl OfferModifier { + /// Sets the order fields on the offer and seals them into an encrypted `payment_token`: + /// the amount and description are committed so the merchant can detect tampering with the + /// offer's cleartext fields, and `order_id` is the per-order secret the merchant relays in + /// its payment notification to authenticate it. + /// + /// The token is encrypted to the template's signing pubkey, which only the merchant can + /// recover the secret key for. `order_id` must be at least + /// [`MIN_ORDER_ID_LEN`](crate::offers::payment_token::MIN_ORDER_ID_LEN) bytes of fresh + /// entropy, and `notification_paths` must terminate at the point-of-sale device with the same + /// order data in their message context. + pub fn with_pos_order( + mut self, amount_msats: u64, description: Option, order_id: Vec, + notification_paths: Vec, absolute_expiry: Option, + entropy_source: ES, secp_ctx: &Secp256k1, + ) -> Result + where + ES::Target: EntropySource, + { + debug_assert!(order_id.len() >= crate::offers::payment_token::MIN_ORDER_ID_LEN); + if notification_paths.is_empty() { + return Err(Bolt12SemanticError::MissingPaths); + } + if amount_msats > MAX_VALUE_MSAT { + return Err(Bolt12SemanticError::InvalidAmount); + } + // An offer with an amount must have a description per BOLT 12. + let description = description.unwrap_or_else(String::new); + + let issuer_signing_pubkey = self + .contents + .issuer_signing_pubkey() + .ok_or(Bolt12SemanticError::MissingIssuerSigningPubkey)?; + let payload = + PaymentTokenPayload { amount_msats, description: Some(description.clone()), order_id }; + let payment_token = payload.encrypt(&issuer_signing_pubkey, entropy_source, secp_ctx); + + self.contents.amount = Some(Amount::Bitcoin { amount_msats }); + self.contents.description = Some(description); + self.contents.notification_paths = Some(notification_paths); + self.contents.payment_token = Some(payment_token); + self.contents.absolute_expiry = absolute_expiry.or(self.contents.absolute_expiry); + + Ok(self) + } + + /// Replaces the `payment_token` without re-encrypting the committed fields, for constructing + /// offers whose cleartext fields do not match the token in tests. + #[cfg(test)] + pub(crate) fn payment_token_unchecked(mut self, payment_token: Vec) -> Self { + self.contents.payment_token = Some(payment_token); + self + } + + /// Builds the per-order [`Offer`] by re-serializing the modified records. + pub fn build(self) -> Offer { + const OFFER_ALLOCATION_SIZE: usize = 1024; + let mut bytes = Vec::with_capacity(OFFER_ALLOCATION_SIZE); + self.contents.write(&mut bytes).unwrap(); + + let id = OfferId::from_valid_offer_tlv_stream(&bytes); + + Offer { bytes, contents: self.contents, id } + } +} + impl OfferContents { pub fn chains(&self) -> Vec { self.chains.as_ref().cloned().unwrap_or_else(|| vec![self.implied_chain()]) @@ -993,6 +1143,14 @@ impl OfferContents { self.issuer_signing_pubkey } + pub fn notification_paths(&self) -> &[BlindedMessagePath] { + self.notification_paths.as_ref().map(|paths| paths.as_slice()).unwrap_or(&[]) + } + + pub fn payment_token(&self) -> Option<&Vec> { + self.payment_token.as_ref() + } + pub(super) fn verify_using_metadata( &self, bytes: &[u8], key: &ExpandedKey, secp_ctx: &Secp256k1, ) -> Result<(OfferId, Option), ()> { @@ -1075,6 +1233,8 @@ impl OfferContents { }; let experimental_offer = ExperimentalOfferTlvStreamRef { + notification_paths: self.notification_paths.as_ref(), + payment_token: self.payment_token.as_ref(), #[cfg(test)] experimental_foo: self.experimental_foo, }; @@ -1231,18 +1391,38 @@ tlv_stream!(OfferTlvStream, OfferTlvStreamRef<'a>, OFFER_TYPES, { /// Valid type range for experimental offer TLV records. pub(super) const EXPERIMENTAL_OFFER_TYPES: core::ops::Range = 1_000_000_000..2_000_000_000; +/// TLV record type for [`Offer::notification_paths`]. +/// +/// Odd so that wallets unaware of the field still accept the offer and mirror the record into the +/// `invoice_request`, as required by [bLIP 56]. +/// +/// [bLIP 56]: https://github.com/lightning/blips/pull/56 +const OFFER_NOTIFICATION_PATHS_TYPE: u64 = 1_000_005_601; + +/// TLV record type for [`Offer::payment_token`]. +/// +/// Odd so that wallets unaware of the field still accept the offer and mirror the record into the +/// `invoice_request`, as required by [bLIP 56]. +/// +/// [bLIP 56]: https://github.com/lightning/blips/pull/56 +const OFFER_PAYMENT_TOKEN_TYPE: u64 = 1_000_005_603; + #[cfg(not(test))] -tlv_stream!(ExperimentalOfferTlvStream, ExperimentalOfferTlvStreamRef, EXPERIMENTAL_OFFER_TYPES, { +tlv_stream!(ExperimentalOfferTlvStream, ExperimentalOfferTlvStreamRef<'a>, EXPERIMENTAL_OFFER_TYPES, { + (OFFER_NOTIFICATION_PATHS_TYPE, notification_paths: (Vec, WithoutLength)), + (OFFER_PAYMENT_TOKEN_TYPE, payment_token: (Vec, WithoutLength)), }); #[cfg(test)] -tlv_stream!(ExperimentalOfferTlvStream, ExperimentalOfferTlvStreamRef, EXPERIMENTAL_OFFER_TYPES, { +tlv_stream!(ExperimentalOfferTlvStream, ExperimentalOfferTlvStreamRef<'a>, EXPERIMENTAL_OFFER_TYPES, { + (OFFER_NOTIFICATION_PATHS_TYPE, notification_paths: (Vec, WithoutLength)), + (OFFER_PAYMENT_TOKEN_TYPE, payment_token: (Vec, WithoutLength)), (1_999_999_999, experimental_foo: (u64, HighZeroBytesDroppedBigSize)), }); type FullOfferTlvStream = (OfferTlvStream, ExperimentalOfferTlvStream); -type FullOfferTlvStreamRef<'a> = (OfferTlvStreamRef<'a>, ExperimentalOfferTlvStreamRef); +type FullOfferTlvStreamRef<'a> = (OfferTlvStreamRef<'a>, ExperimentalOfferTlvStreamRef<'a>); impl CursorReadable for FullOfferTlvStream { fn read>(r: &mut io::Cursor) -> Result { @@ -1297,6 +1477,8 @@ impl TryFrom for OfferContents { issuer_id, }, ExperimentalOfferTlvStream { + notification_paths, + payment_token, #[cfg(test)] experimental_foo, }, @@ -1353,6 +1535,8 @@ impl TryFrom for OfferContents { paths, supported_quantity, issuer_signing_pubkey, + notification_paths, + payment_token, #[cfg(test)] experimental_foo, }) @@ -1447,7 +1631,11 @@ mod tests { quantity_max: None, issuer_id: Some(&pubkey(42)), }, - ExperimentalOfferTlvStreamRef { experimental_foo: None }, + ExperimentalOfferTlvStreamRef { + notification_paths: None, + payment_token: None, + experimental_foo: None, + }, ), ); diff --git a/lightning/src/offers/parse.rs b/lightning/src/offers/parse.rs index df71e860d2d..ae3f4036bc2 100644 --- a/lightning/src/offers/parse.rs +++ b/lightning/src/offers/parse.rs @@ -190,6 +190,8 @@ pub enum Bolt12SemanticError { UnexpectedFeatures, /// A required description was not provided. MissingDescription, + /// A description was provided but was not expected. + UnexpectedDescription, /// An issuer's signing pubkey was not provided. MissingIssuerSigningPubkey, /// An issuer's signing pubkey was provided but was not expected. @@ -214,6 +216,8 @@ pub enum Bolt12SemanticError { MissingPaths, /// Blinded paths were provided but were not expected. UnexpectedPaths, + /// A payment token was provided but was not expected. + UnexpectedPaymentToken, /// The blinded payinfo given does not match the number of blinded path hops. InvalidPayInfo, /// An invoice creation time was expected but was missing. diff --git a/lightning/src/offers/refund.rs b/lightning/src/offers/refund.rs index c0fd9dfdd3e..67473542544 100644 --- a/lightning/src/offers/refund.rs +++ b/lightning/src/offers/refund.rs @@ -804,6 +804,8 @@ impl RefundContents { }; let experimental_offer = ExperimentalOfferTlvStreamRef { + notification_paths: None, + payment_token: None, #[cfg(test)] experimental_foo: self.experimental_foo, }; @@ -853,7 +855,7 @@ type RefundTlvStreamRef<'a> = ( PayerTlvStreamRef<'a>, OfferTlvStreamRef<'a>, InvoiceRequestTlvStreamRef<'a>, - ExperimentalOfferTlvStreamRef, + ExperimentalOfferTlvStreamRef<'a>, ExperimentalInvoiceRequestTlvStreamRef, ); @@ -923,6 +925,8 @@ impl TryFrom for RefundContents { offer_from_hrn, }, ExperimentalOfferTlvStream { + notification_paths: _, + payment_token: _, #[cfg(test)] experimental_foo, }, @@ -1112,7 +1116,11 @@ mod tests { paths: None, offer_from_hrn: None, }, - ExperimentalOfferTlvStreamRef { experimental_foo: None }, + ExperimentalOfferTlvStreamRef { + notification_paths: None, + payment_token: None, + experimental_foo: None, + }, ExperimentalInvoiceRequestTlvStreamRef { experimental_bar: None }, ), ); diff --git a/lightning/src/offers/signer.rs b/lightning/src/offers/signer.rs index e51a120b6d7..bfd058ed920 100644 --- a/lightning/src/offers/signer.rs +++ b/lightning/src/offers/signer.rs @@ -321,6 +321,32 @@ pub(super) fn derive_keys(nonce: Nonce, expanded_key: &ExpandedKey) -> Keypair { Keypair::from_secret_key(&secp_ctx, &privkey) } +/// Derives a signing [`Keypair`] for a point-of-sale offer template from the given [`Nonce`] and +/// [`ExpandedKey`]. +/// +/// Unlike the metadata-bound key derivation used for standard offers, the derivation has no +/// dependency on the offer's TLV records. This allows a point-of-sale device to modify a template +/// offer (adding an amount, notification paths, and a payment token) without invalidating the +/// merchant's ability to re-derive the signing key when the corresponding `invoice_request` +/// arrives. +/// +/// Authentication that the nonce is genuine is provided by the [`ReceiveAuthKey`]-based +/// authentication of the blinded message path carrying the +/// [`OffersContext::DelegatedInvoiceRequest`]. +/// +/// [`ReceiveAuthKey`]: crate::sign::ReceiveAuthKey +/// [`OffersContext::DelegatedInvoiceRequest`]: crate::blinded_path::message::OffersContext::DelegatedInvoiceRequest +pub(super) fn derive_keys_for_delegation(nonce: Nonce, expanded_key: &ExpandedKey) -> Keypair { + const IV_BYTES: &[u8; IV_LEN] = b"LDK Delegation ~"; + let mut hmac = expanded_key.hmac_for_offer(); + hmac.input(IV_BYTES); + hmac.input(&nonce.0); + + let secp_ctx = Secp256k1::new(); + let privkey = SecretKey::from_slice(Hmac::from_engine(hmac).as_byte_array()).unwrap(); + Keypair::from_secret_key(&secp_ctx, &privkey) +} + /// Verifies data given in a TLV stream was used to produce the given metadata, consisting of: /// - a 256-bit [`PaymentId`], /// - a 128-bit [`Nonce`], and possibly diff --git a/lightning/src/offers/static_invoice.rs b/lightning/src/offers/static_invoice.rs index 860835912a1..15eec568d6f 100644 --- a/lightning/src/offers/static_invoice.rs +++ b/lightning/src/offers/static_invoice.rs @@ -625,7 +625,7 @@ type PartialInvoiceTlvStream = type PartialInvoiceTlvStreamRef<'a> = ( OfferTlvStreamRef<'a>, InvoiceTlvStreamRef<'a>, - ExperimentalOfferTlvStreamRef, + ExperimentalOfferTlvStreamRef<'a>, ExperimentalInvoiceTlvStreamRef, ); @@ -768,7 +768,7 @@ mod tests { OfferTlvStreamRef<'a>, InvoiceTlvStreamRef<'a>, SignatureTlvStreamRef<'a>, - ExperimentalOfferTlvStreamRef, + ExperimentalOfferTlvStreamRef<'a>, ExperimentalInvoiceTlvStreamRef, ); @@ -934,7 +934,11 @@ mod tests { held_htlc_available_paths: Some(&paths), }, SignatureTlvStreamRef { signature: Some(&invoice.signature()) }, - ExperimentalOfferTlvStreamRef { experimental_foo: None }, + ExperimentalOfferTlvStreamRef { + notification_paths: None, + payment_token: None, + experimental_foo: None, + }, ExperimentalInvoiceTlvStreamRef { experimental_baz: None }, ) ); From 84643c4db3cc967dc4a046c5046fc42435e0de8f Mon Sep 17 00:00:00 2001 From: Vincenzo Palazzo Date: Wed, 10 Jun 2026 18:59:48 +0200 Subject: [PATCH 4/6] onion_message: Add bLIP 56 point-of-sale notification messages Adds the payment_notification, notification_ack, notification_nack, and payment_proof onion messages (TLV types 65560 through 65566, provisional) along with a PosNotificationMessageHandler wired through the OnionMessenger, and a PosNotificationContext for the blinded paths carrying them. A per-order offer's notification_path carries the full order state in its message context, authenticated by the path itself, so the point-of-sale device needs no persistent order state to validate incoming notifications. The merchant's reply path context identifies the pending notification an ack or nack refers to. Co-Authored-By: Claude Fable 5 --- lightning-background-processor/src/lib.rs | 5 +- lightning-dns-resolver/src/lib.rs | 4 + lightning/src/blinded_path/message.rs | 76 ++++ lightning/src/ln/functional_test_utils.rs | 2 + lightning/src/ln/peer_handler.rs | 23 +- .../src/onion_message/functional_tests.rs | 28 +- lightning/src/onion_message/messenger.rs | 90 ++++- lightning/src/onion_message/mod.rs | 1 + lightning/src/onion_message/packet.rs | 12 + .../src/onion_message/pos_notification.rs | 366 ++++++++++++++++++ 10 files changed, 593 insertions(+), 14 deletions(-) create mode 100644 lightning/src/onion_message/pos_notification.rs diff --git a/lightning-background-processor/src/lib.rs b/lightning-background-processor/src/lib.rs index 8ab20d5a1f3..19282a462f6 100644 --- a/lightning-background-processor/src/lib.rs +++ b/lightning-background-processor/src/lib.rs @@ -410,6 +410,7 @@ pub const NO_ONION_MESSENGER: Option< OMH = lightning::ln::peer_handler::IgnoringMessageHandler, APH = lightning::ln::peer_handler::IgnoringMessageHandler, DRH = lightning::ln::peer_handler::IgnoringMessageHandler, + PNH = lightning::ln::peer_handler::IgnoringMessageHandler, CMH = lightning::ln::peer_handler::IgnoringMessageHandler, > + Send + Sync, @@ -830,7 +831,7 @@ use futures_util::{dummy_waker, Joiner, OptionalSelector, Selector, SelectorOutp /// # type NetworkGraph = lightning::routing::gossip::NetworkGraph>; /// # type P2PGossipSync
    = lightning::routing::gossip::P2PGossipSync, Arc
      , Arc>; /// # type ChannelManager = lightning::ln::channelmanager::SimpleArcChannelManager, B, FE, Logger>; -/// # type OnionMessenger = lightning::onion_message::messenger::OnionMessenger, Arc, Arc, Arc>, Arc, Arc, Arc>>, Arc>, lightning::ln::peer_handler::IgnoringMessageHandler, lightning::ln::peer_handler::IgnoringMessageHandler, lightning::ln::peer_handler::IgnoringMessageHandler>; +/// # type OnionMessenger = lightning::onion_message::messenger::OnionMessenger, Arc, Arc, Arc>, Arc, Arc, Arc>>, Arc>, lightning::ln::peer_handler::IgnoringMessageHandler, lightning::ln::peer_handler::IgnoringMessageHandler, lightning::ln::peer_handler::IgnoringMessageHandler, lightning::ln::peer_handler::IgnoringMessageHandler>; /// # type LiquidityManager = lightning_liquidity::LiquidityManager, Arc, Arc>, Arc, Arc, Arc>; /// # type Scorer = RwLock, Arc>>; /// # type PeerManager = lightning::ln::peer_handler::SimpleArcPeerManager, B, FE, Arc
        , Logger, F, StoreSync>; @@ -2087,6 +2088,7 @@ mod tests { Arc, IgnoringMessageHandler, IgnoringMessageHandler, + IgnoringMessageHandler, >; type LM = LiquidityManagerSync< @@ -2512,6 +2514,7 @@ mod tests { Arc::clone(&manager), IgnoringMessageHandler {}, IgnoringMessageHandler {}, + IgnoringMessageHandler {}, )); let wallet = Arc::new(TestWallet {}); let sweeper = Arc::new(OutputSweeperSync::new( diff --git a/lightning-dns-resolver/src/lib.rs b/lightning-dns-resolver/src/lib.rs index 90f1ac01f02..ba612b04c01 100644 --- a/lightning-dns-resolver/src/lib.rs +++ b/lightning-dns-resolver/src/lib.rs @@ -250,6 +250,7 @@ mod test { IgnoringMessageHandler {}, Arc::clone(&resolver), IgnoringMessageHandler {}, + IgnoringMessageHandler {}, ), resolver_keys.get_node_id(Recipient::Node).unwrap(), ) @@ -292,6 +293,7 @@ mod test { IgnoringMessageHandler {}, Arc::clone(&payer), IgnoringMessageHandler {}, + IgnoringMessageHandler {}, )); let init_msg = get_om_init(); @@ -363,6 +365,7 @@ mod test { IgnoringMessageHandler {}, Arc::clone(&resolver), IgnoringMessageHandler {}, + IgnoringMessageHandler {}, ); let resolver_id = resolver_keys.get_node_id(Recipient::Node).unwrap(); @@ -390,6 +393,7 @@ mod test { IgnoringMessageHandler {}, Arc::clone(&payer), IgnoringMessageHandler {}, + IgnoringMessageHandler {}, )); let init_msg = get_om_init(); diff --git a/lightning/src/blinded_path/message.rs b/lightning/src/blinded_path/message.rs index 417c66374a9..eacce5bc5ab 100644 --- a/lightning/src/blinded_path/message.rs +++ b/lightning/src/blinded_path/message.rs @@ -375,6 +375,10 @@ pub enum MessageContext { /// /// [`CustomOnionMessageHandler::CustomMessage`]: crate::onion_message::messenger::CustomOnionMessageHandler::CustomMessage Custom(Vec), + /// Context specific to a [`PosNotificationMessage`]. + /// + /// [`PosNotificationMessage`]: crate::onion_message::pos_notification::PosNotificationMessage + PosNotification(PosNotificationContext), } /// Contains data specific to an [`OffersMessage`]. @@ -509,6 +513,21 @@ pub enum OffersContext { /// [`Bolt12Invoice::payment_hash`]: crate::offers::invoice::Bolt12Invoice::payment_hash payment_hash: PaymentHash, }, + /// Context used by a [`BlindedMessagePath`] within an [`Offer`] template handed to a + /// point-of-sale device for per-order offer construction. + /// + /// This variant is intended to be received when handling an [`InvoiceRequest`] for an offer the + /// point-of-sale device derived from the template. Since the device modifies the offer's TLV + /// records, the offer cannot be authenticated via its metadata; instead, the signing keys are + /// re-derived from the nonce alone and matched against the offer's signing pubkey. + /// + /// [`Offer`]: crate::offers::offer::Offer + /// [`InvoiceRequest`]: crate::offers::invoice_request::InvoiceRequest + DelegatedInvoiceRequest { + /// A nonce used for deriving the signing keys of the offer template independently of its TLV + /// records. + nonce: Nonce, + }, } /// Contains data specific to an [`AsyncPaymentsMessage`]. @@ -665,6 +684,7 @@ impl_ser_tlv_based_enum!(MessageContext, {1, Custom} => (), {2, AsyncPayments} => (), {3, DNSResolver} => (), + {4, PosNotification} => (), ); // Note: Several TLV fields (`nonce`, `hmac`, etc.) were removed in LDK v0.2 following the @@ -692,6 +712,9 @@ impl_ser_tlv_based_enum!(OffersContext, (0, payment_id, required), (1, nonce, required), }, + (5, DelegatedInvoiceRequest) => { + (0, nonce, required), + }, ); impl_ser_tlv_based_enum!(AsyncPaymentsContext, @@ -741,6 +764,59 @@ impl_ser_tlv_based!(DNSResolverContext, { (0, nonce, required), }); +/// Contains data specific to a [`PosNotificationMessage`]. +/// +/// [`PosNotificationMessage`]: crate::onion_message::pos_notification::PosNotificationMessage +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum PosNotificationContext { + /// Context used by a point-of-sale device in a per-order offer's `notification_path`. + /// + /// This variant is intended to be received when handling a `payment_notification` from the + /// merchant or a `payment_proof` from the customer. It carries the full order state so that + /// the device does not need to persist outstanding orders: the path-level authentication of + /// the blinded path guarantees the context was created by this device, and matching + /// [`Self::InboundOrder::order_id`] against the `order_id` relayed in the notification + /// authenticates the notification as merchant-originated. + InboundOrder { + /// The per-order secret also sealed into the offer's `payment_token`. Only the merchant + /// can recover it from the token, so a notification relaying it must have come from the + /// merchant. + order_id: Vec, + + /// The amount in millisatoshis set on the per-order offer. A notification for a smaller + /// amount must be rejected. + amount_msats: u64, + + /// The description set on the per-order offer, if any. + description: Option, + + /// The time as duration since the Unix epoch at which the order expires and notifications + /// for it should be rejected. Set from the per-order offer's `offer_absolute_expiry`. + order_absolute_expiry: Option, + }, + /// Context used by a merchant in the reply path of a `payment_notification`. + /// + /// This variant is intended to be received when handling a `notification_ack` or + /// `notification_nack` from the point-of-sale device, and identifies the pending notification + /// being acknowledged. + OutboundNotification { + /// The payment hash of the claimed payment the notification was sent for. + payment_hash: PaymentHash, + }, +} + +impl_ser_tlv_based_enum!(PosNotificationContext, + (0, InboundOrder) => { + (0, order_id, required_vec), + (2, amount_msats, required), + (3, description, option), + (4, order_absolute_expiry, option), + }, + (1, OutboundNotification) => { + (0, payment_hash, required), + }, +); + /// Represents the padding round off size (in bytes) that is used /// to pad message blinded path's [`BlindedHop`] pub(crate) const MESSAGE_PADDING_ROUND_OFF: usize = 100; diff --git a/lightning/src/ln/functional_test_utils.rs b/lightning/src/ln/functional_test_utils.rs index 20fcbef0dba..9697d249579 100644 --- a/lightning/src/ln/functional_test_utils.rs +++ b/lightning/src/ln/functional_test_utils.rs @@ -534,6 +534,7 @@ type TestOnionMessenger<'chan_man, 'node_cfg, 'chan_mon_cfg> = OnionMessenger< &'chan_man TestChannelManager<'node_cfg, 'chan_mon_cfg>, IgnoringMessageHandler, IgnoringMessageHandler, + IgnoringMessageHandler, >; /// For use with [`OnionMessenger`] otherwise `test_restored_packages_retry` will fail. This is @@ -4799,6 +4800,7 @@ pub fn create_network<'a, 'b: 'a, 'c: 'b>( &chan_mgrs[i], IgnoringMessageHandler {}, IgnoringMessageHandler {}, + IgnoringMessageHandler {}, ); let gossip_sync = P2PGossipSync::new(cfgs[i].network_graph.as_ref(), None, cfgs[i].logger); let wallet_source = Arc::new(test_utils::TestWalletSource::new( diff --git a/lightning/src/ln/peer_handler.rs b/lightning/src/ln/peer_handler.rs index 69d0815e8f0..847e2b03ae6 100644 --- a/lightning/src/ln/peer_handler.rs +++ b/lightning/src/ln/peer_handler.rs @@ -18,7 +18,9 @@ use bitcoin::constants::ChainHash; use bitcoin::secp256k1::{self, PublicKey, Secp256k1, SecretKey}; -use crate::blinded_path::message::{AsyncPaymentsContext, DNSResolverContext, OffersContext}; +use crate::blinded_path::message::{ + AsyncPaymentsContext, DNSResolverContext, OffersContext, PosNotificationContext, +}; use crate::ln::msgs; use crate::ln::msgs::{ BaseMessageHandler, ChannelMessageHandler, Init, LightningError, MessageSendEvent, @@ -42,6 +44,10 @@ use crate::onion_message::messenger::{ }; use crate::onion_message::offers::{OffersMessage, OffersMessageHandler}; use crate::onion_message::packet::OnionMessageContents; +use crate::onion_message::pos_notification::{ + NotificationAck, NotificationNack, PaymentNotification, PaymentProof, PosNotificationMessage, + PosNotificationMessageHandler, +}; use crate::routing::gossip::{NodeAlias, NodeId}; use crate::sign::{NodeSigner, Recipient}; use crate::types::features::{InitFeatures, NodeFeatures}; @@ -275,6 +281,21 @@ impl DNSResolverMessageHandler for IgnoringMessageHandler { } fn handle_dnssec_proof(&self, _message: DNSSECProof, _context: DNSResolverContext) {} } +impl PosNotificationMessageHandler for IgnoringMessageHandler { + fn handle_payment_notification( + &self, _message: PaymentNotification, _context: PosNotificationContext, + _responder: Option, + ) -> Option<(PosNotificationMessage, ResponseInstruction)> { + None + } + fn handle_notification_ack(&self, _message: NotificationAck, _context: PosNotificationContext) { + } + fn handle_notification_nack( + &self, _message: NotificationNack, _context: PosNotificationContext, + ) { + } + fn handle_payment_proof(&self, _message: PaymentProof, _context: PosNotificationContext) {} +} impl CustomOnionMessageHandler for IgnoringMessageHandler { type CustomMessage = Infallible; fn handle_custom_message( diff --git a/lightning/src/onion_message/functional_tests.rs b/lightning/src/onion_message/functional_tests.rs index 75e2aaf3c5f..1a15873ded7 100644 --- a/lightning/src/onion_message/functional_tests.rs +++ b/lightning/src/onion_message/functional_tests.rs @@ -22,9 +22,13 @@ use super::messenger::{ }; use super::offers::{OffersMessage, OffersMessageHandler}; use super::packet::{OnionMessageContents, Packet}; +use super::pos_notification::{ + NotificationAck, NotificationNack, PaymentNotification, PaymentProof, PosNotificationMessage, + PosNotificationMessageHandler, +}; use crate::blinded_path::message::{ AsyncPaymentsContext, BlindedMessagePath, DNSResolverContext, MessageContext, - MessageForwardNode, OffersContext, MESSAGE_PADDING_ROUND_OFF, + MessageForwardNode, OffersContext, PosNotificationContext, MESSAGE_PADDING_ROUND_OFF, }; use crate::blinded_path::utils::is_padded; use crate::blinded_path::EmptyNodeIdLookUp; @@ -65,6 +69,7 @@ struct MessengerNode { Arc, Arc, Arc, + Arc, Arc, >, custom_message_handler: Arc, @@ -134,6 +139,24 @@ impl DNSResolverMessageHandler for TestDNSResolverMessageHandler { fn handle_dnssec_proof(&self, _message: DNSSECProof, _context: DNSResolverContext) {} } +struct TestPosNotificationMessageHandler {} + +impl PosNotificationMessageHandler for TestPosNotificationMessageHandler { + fn handle_payment_notification( + &self, _message: PaymentNotification, _context: PosNotificationContext, + _responder: Option, + ) -> Option<(PosNotificationMessage, ResponseInstruction)> { + None + } + fn handle_notification_ack(&self, _message: NotificationAck, _context: PosNotificationContext) { + } + fn handle_notification_nack( + &self, _message: NotificationNack, _context: PosNotificationContext, + ) { + } + fn handle_payment_proof(&self, _message: PaymentProof, _context: PosNotificationContext) {} +} + #[derive(Clone, Debug, PartialEq)] enum TestCustomMessage { Ping, @@ -310,6 +333,7 @@ fn create_nodes_using_cfgs(cfgs: Vec) -> Vec { let offers_message_handler = Arc::new(TestOffersMessageHandler {}); let async_payments_message_handler = Arc::new(TestAsyncPaymentsMessageHandler {}); let dns_resolver_message_handler = Arc::new(TestDNSResolverMessageHandler {}); + let pos_notification_message_handler = Arc::new(TestPosNotificationMessageHandler {}); let custom_message_handler = Arc::new(TestCustomMessageHandler::new()); let messenger = if cfg.intercept_offline_peer_oms { OnionMessenger::new_with_offline_peer_interception( @@ -321,6 +345,7 @@ fn create_nodes_using_cfgs(cfgs: Vec) -> Vec { offers_message_handler, async_payments_message_handler, dns_resolver_message_handler, + pos_notification_message_handler, Arc::clone(&custom_message_handler), ) } else { @@ -333,6 +358,7 @@ fn create_nodes_using_cfgs(cfgs: Vec) -> Vec { offers_message_handler, async_payments_message_handler, dns_resolver_message_handler, + pos_notification_message_handler, Arc::clone(&custom_message_handler), ) }; diff --git a/lightning/src/onion_message/messenger.rs b/lightning/src/onion_message/messenger.rs index 913a04637b9..a92ecc0e17d 100644 --- a/lightning/src/onion_message/messenger.rs +++ b/lightning/src/onion_message/messenger.rs @@ -24,9 +24,10 @@ use super::packet::{ ForwardControlTlvs, Packet, Payload, ReceiveControlTlvs, BIG_PACKET_HOP_DATA_LEN, SMALL_PACKET_HOP_DATA_LEN, }; +use super::pos_notification::{PosNotificationMessage, PosNotificationMessageHandler}; use crate::blinded_path::message::{ AsyncPaymentsContext, BlindedMessagePath, DNSResolverContext, ForwardTlvs, MessageContext, - MessageForwardNode, NextMessageHop, OffersContext, ReceiveTlvs, + MessageForwardNode, NextMessageHop, OffersContext, PosNotificationContext, ReceiveTlvs, }; use crate::blinded_path::utils; use crate::blinded_path::{IntroductionNode, NodeIdLookUp}; @@ -81,6 +82,8 @@ pub trait AOnionMessenger { type APH: AsyncPaymentsMessageHandler; /// A type implementing [`DNSResolverMessageHandler`] type DRH: DNSResolverMessageHandler; + /// A type implementing [`PosNotificationMessageHandler`] + type PNH: PosNotificationMessageHandler; /// A type implementing [`CustomOnionMessageHandler`] type CMH: CustomOnionMessageHandler; /// Returns a reference to the actual [`OnionMessenger`] object. @@ -95,6 +98,7 @@ pub trait AOnionMessenger { Self::OMH, Self::APH, Self::DRH, + Self::PNH, Self::CMH, >; } @@ -108,8 +112,9 @@ impl< OMH: OffersMessageHandler, APH: AsyncPaymentsMessageHandler, DRH: DNSResolverMessageHandler, + PNH: PosNotificationMessageHandler, CMH: CustomOnionMessageHandler, - > AOnionMessenger for OnionMessenger + > AOnionMessenger for OnionMessenger { type EntropySource = ES; type NodeSigner = NS; @@ -119,8 +124,9 @@ impl< type OMH = OMH; type APH = APH; type DRH = DRH; + type PNH = PNH; type CMH = CMH; - fn get_om(&self) -> &OnionMessenger { + fn get_om(&self) -> &OnionMessenger { self } } @@ -198,12 +204,13 @@ impl< /// # let offers_message_handler = IgnoringMessageHandler {}; /// # let async_payments_message_handler = IgnoringMessageHandler {}; /// # let dns_resolution_message_handler = IgnoringMessageHandler {}; +/// # let pos_notification_message_handler = IgnoringMessageHandler {}; /// // Create the onion messenger. This must use the same `keys_manager` as is passed to your /// // ChannelManager. /// let onion_messenger = OnionMessenger::new( /// &keys_manager, &keys_manager, logger, &node_id_lookup, message_router, /// &offers_message_handler, &async_payments_message_handler, &dns_resolution_message_handler, -/// &custom_message_handler, +/// &pos_notification_message_handler, &custom_message_handler, /// ); /// /// # #[derive(Debug)] @@ -255,6 +262,7 @@ pub struct OnionMessenger< OMH: OffersMessageHandler, APH: AsyncPaymentsMessageHandler, DRH: DNSResolverMessageHandler, + PNH: PosNotificationMessageHandler, CMH: CustomOnionMessageHandler, > { entropy_source: ES, @@ -271,6 +279,7 @@ pub struct OnionMessenger< #[allow(unused)] async_payments_handler: APH, dns_resolver_handler: DRH, + pos_notification_handler: PNH, custom_handler: CMH, intercept_messages_for_offline_peers: bool, pending_intercepted_msgs_events: Mutex>, @@ -1018,6 +1027,8 @@ pub enum PeeledOnion { AsyncPayments(AsyncPaymentsMessage, AsyncPaymentsContext, Option), /// Received DNS resolver onion message, with decrypted contents, context, and reply path DNSResolver(DNSResolverMessage, Option, Option), + /// Received PoS notification onion message, with decrypted contents, context, and reply path + PosNotification(PosNotificationMessage, PosNotificationContext, Option), /// Received custom onion message, with decrypted contents, context, and reply path Custom(T, Option>, Option), } @@ -1283,6 +1294,19 @@ pub fn peel_onion_message { Ok(PeeledOnion::DNSResolver(msg, None, reply_path)) }, + ( + ParsedOnionMessageContents::PosNotification(msg), + Some(MessageContext::PosNotification(ctx)), + ) => { + if !control_tlvs_from_local_node { + log_trace!( + logger, + "Received an unauthenticated PoS notification onion message" + ); + return Err(()); + } + Ok(PeeledOnion::PosNotification(msg, ctx, reply_path)) + }, _ => { log_trace!( logger, @@ -1373,14 +1397,16 @@ impl< OMH: OffersMessageHandler, APH: AsyncPaymentsMessageHandler, DRH: DNSResolverMessageHandler, + PNH: PosNotificationMessageHandler, CMH: CustomOnionMessageHandler, - > OnionMessenger + > OnionMessenger { /// Constructs a new `OnionMessenger` to send, forward, and delegate received onion messages to /// their respective handlers. pub fn new( entropy_source: ES, node_signer: NS, logger: L, node_id_lookup: NL, message_router: MR, - offers_handler: OMH, async_payments_handler: APH, dns_resolver: DRH, custom_handler: CMH, + offers_handler: OMH, async_payments_handler: APH, dns_resolver: DRH, + pos_notification_handler: PNH, custom_handler: CMH, ) -> Self { Self::new_inner( entropy_source, @@ -1391,6 +1417,7 @@ impl< offers_handler, async_payments_handler, dns_resolver, + pos_notification_handler, custom_handler, false, ) @@ -1419,7 +1446,8 @@ impl< /// peers. pub fn new_with_offline_peer_interception( entropy_source: ES, node_signer: NS, logger: L, node_id_lookup: NL, message_router: MR, - offers_handler: OMH, async_payments_handler: APH, dns_resolver: DRH, custom_handler: CMH, + offers_handler: OMH, async_payments_handler: APH, dns_resolver: DRH, + pos_notification_handler: PNH, custom_handler: CMH, ) -> Self { Self::new_inner( entropy_source, @@ -1430,6 +1458,7 @@ impl< offers_handler, async_payments_handler, dns_resolver, + pos_notification_handler, custom_handler, true, ) @@ -1437,7 +1466,8 @@ impl< fn new_inner( entropy_source: ES, node_signer: NS, logger: L, node_id_lookup: NL, message_router: MR, - offers_handler: OMH, async_payments_handler: APH, dns_resolver: DRH, custom_handler: CMH, + offers_handler: OMH, async_payments_handler: APH, dns_resolver: DRH, + pos_notification_handler: PNH, custom_handler: CMH, intercept_messages_for_offline_peers: bool, ) -> Self { let mut secp_ctx = Secp256k1::new(); @@ -1453,6 +1483,7 @@ impl< offers_handler, async_payments_handler, dns_resolver_handler: dns_resolver, + pos_notification_handler, custom_handler, intercept_messages_for_offline_peers, pending_intercepted_msgs_events: Mutex::new(Vec::new()), @@ -1836,6 +1867,15 @@ impl< ); } + // Enqueue any initiating `PosNotificationMessage`s to send. + for (message, instructions) in self.pos_notification_handler.release_pending_messages() { + let _ = self.send_onion_message_internal( + message, + instructions, + format_args!("when sending PosNotificationMessage"), + ); + } + // Enqueue any initiating `CustomMessage`s to send. for (message, instructions) in self.custom_handler.release_pending_custom_messages() { let _ = self.send_onion_message_internal( @@ -2007,8 +2047,9 @@ impl< OMH: OffersMessageHandler, APH: AsyncPaymentsMessageHandler, DRH: DNSResolverMessageHandler, + PNH: PosNotificationMessageHandler, CMH: CustomOnionMessageHandler, - > EventsProvider for OnionMessenger + > EventsProvider for OnionMessenger { fn process_pending_events(&self, handler: H) where @@ -2118,8 +2159,9 @@ impl< OMH: OffersMessageHandler, APH: AsyncPaymentsMessageHandler, DRH: DNSResolverMessageHandler, + PNH: PosNotificationMessageHandler, CMH: CustomOnionMessageHandler, - > BaseMessageHandler for OnionMessenger + > BaseMessageHandler for OnionMessenger { fn provided_node_features(&self) -> NodeFeatures { let mut features = NodeFeatures::empty(); @@ -2180,8 +2222,9 @@ impl< OMH: OffersMessageHandler, APH: AsyncPaymentsMessageHandler, DRH: DNSResolverMessageHandler, + PNH: PosNotificationMessageHandler, CMH: CustomOnionMessageHandler, - > OnionMessageHandler for OnionMessenger + > OnionMessageHandler for OnionMessenger { fn handle_onion_message(&self, peer_node_id: PublicKey, msg: &OnionMessage) { let logger = WithContext::from(&self.logger, Some(peer_node_id), None, None); @@ -2279,6 +2322,29 @@ impl< }, } }, + Ok(PeeledOnion::PosNotification(message, context, reply_path)) => { + log_receive!(message, reply_path.is_some()); + let responder = reply_path.map(Responder::new); + match message { + PosNotificationMessage::PaymentNotification(msg) => { + let response_instructions = self + .pos_notification_handler + .handle_payment_notification(msg, context, responder); + if let Some((msg, instructions)) = response_instructions { + let _ = self.handle_onion_message_response(msg, instructions); + } + }, + PosNotificationMessage::NotificationAck(msg) => { + self.pos_notification_handler.handle_notification_ack(msg, context); + }, + PosNotificationMessage::NotificationNack(msg) => { + self.pos_notification_handler.handle_notification_nack(msg, context); + }, + PosNotificationMessage::PaymentProof(msg) => { + self.pos_notification_handler.handle_payment_proof(msg, context); + }, + } + }, Ok(PeeledOnion::Custom(message, context, reply_path)) => { log_receive!(message, reply_path.is_some()); let responder = reply_path.map(Responder::new); @@ -2351,6 +2417,7 @@ pub type SimpleArcOnionMessenger = OnionMessenger< Arc>, Arc>, IgnoringMessageHandler, + Arc>, IgnoringMessageHandler, >; @@ -2372,6 +2439,7 @@ pub type SimpleRefOnionMessenger<'a, 'b, 'c, 'd, 'e, 'f, 'g, 'h, 'i, 'j, M, T, F &'j SimpleRefChannelManager<'a, 'b, 'c, 'd, 'e, 'f, 'g, 'h, 'i, M, T, F, L>, &'j SimpleRefChannelManager<'a, 'b, 'c, 'd, 'e, 'f, 'g, 'h, 'i, M, T, F, L>, IgnoringMessageHandler, + &'j SimpleRefChannelManager<'a, 'b, 'c, 'd, 'e, 'f, 'g, 'h, 'i, M, T, F, L>, IgnoringMessageHandler, >; diff --git a/lightning/src/onion_message/mod.rs b/lightning/src/onion_message/mod.rs index 79fd69f2cbe..0475a00169f 100644 --- a/lightning/src/onion_message/mod.rs +++ b/lightning/src/onion_message/mod.rs @@ -28,3 +28,4 @@ mod functional_tests; pub mod messenger; pub mod offers; pub mod packet; +pub mod pos_notification; diff --git a/lightning/src/onion_message/packet.rs b/lightning/src/onion_message/packet.rs index cd9a923b070..38a7f42ee9b 100644 --- a/lightning/src/onion_message/packet.rs +++ b/lightning/src/onion_message/packet.rs @@ -16,6 +16,7 @@ use super::async_payments::AsyncPaymentsMessage; use super::dns_resolution::DNSResolverMessage; use super::messenger::CustomOnionMessageHandler; use super::offers::OffersMessage; +use super::pos_notification::PosNotificationMessage; use crate::blinded_path::message::{ BlindedMessagePath, DummyTlv, ForwardTlvs, NextMessageHop, ReceiveTlvs, }; @@ -149,6 +150,8 @@ pub enum ParsedOnionMessageContents { AsyncPayments(AsyncPaymentsMessage), /// A message requesting or providing a DNSSEC proof DNSResolver(DNSResolverMessage), + /// A message related to point-of-sale payment notifications. + PosNotification(PosNotificationMessage), /// A custom onion message specified by the user. Custom(T), } @@ -162,6 +165,7 @@ impl OnionMessageContents for ParsedOnionMessageContent &ParsedOnionMessageContents::Offers(ref msg) => msg.tlv_type(), &ParsedOnionMessageContents::AsyncPayments(ref msg) => msg.tlv_type(), &ParsedOnionMessageContents::DNSResolver(ref msg) => msg.tlv_type(), + &ParsedOnionMessageContents::PosNotification(ref msg) => msg.tlv_type(), &ParsedOnionMessageContents::Custom(ref msg) => msg.tlv_type(), } } @@ -171,6 +175,7 @@ impl OnionMessageContents for ParsedOnionMessageContent ParsedOnionMessageContents::Offers(ref msg) => msg.msg_type(), ParsedOnionMessageContents::AsyncPayments(ref msg) => msg.msg_type(), ParsedOnionMessageContents::DNSResolver(ref msg) => msg.msg_type(), + ParsedOnionMessageContents::PosNotification(ref msg) => msg.msg_type(), ParsedOnionMessageContents::Custom(ref msg) => msg.msg_type(), } } @@ -180,6 +185,7 @@ impl OnionMessageContents for ParsedOnionMessageContent ParsedOnionMessageContents::Offers(ref msg) => msg.msg_type(), ParsedOnionMessageContents::AsyncPayments(ref msg) => msg.msg_type(), ParsedOnionMessageContents::DNSResolver(ref msg) => msg.msg_type(), + ParsedOnionMessageContents::PosNotification(ref msg) => msg.msg_type(), ParsedOnionMessageContents::Custom(ref msg) => msg.msg_type(), } } @@ -191,6 +197,7 @@ impl Writeable for ParsedOnionMessageContents { ParsedOnionMessageContents::Offers(msg) => msg.write(w), ParsedOnionMessageContents::AsyncPayments(msg) => msg.write(w), ParsedOnionMessageContents::DNSResolver(msg) => msg.write(w), + ParsedOnionMessageContents::PosNotification(msg) => msg.write(w), ParsedOnionMessageContents::Custom(msg) => msg.write(w), } } @@ -322,6 +329,11 @@ impl message = Some(ParsedOnionMessageContents::DNSResolver(msg)); Ok(true) }, + tlv_type if PosNotificationMessage::is_known_type(tlv_type) => { + let msg = PosNotificationMessage::read(msg_reader, tlv_type)?; + message = Some(ParsedOnionMessageContents::PosNotification(msg)); + Ok(true) + }, _ => match handler.read_custom_message(msg_type, msg_reader)? { Some(msg) => { message = Some(ParsedOnionMessageContents::Custom(msg)); diff --git a/lightning/src/onion_message/pos_notification.rs b/lightning/src/onion_message/pos_notification.rs new file mode 100644 index 00000000000..620c5b9ab1a --- /dev/null +++ b/lightning/src/onion_message/pos_notification.rs @@ -0,0 +1,366 @@ +// This file is Copyright its original authors, visible in version control +// history. +// +// This file is licensed under the Apache License, Version 2.0 or the MIT license +// , at your option. +// You may not use this file except in accordance with one or both of these +// licenses. + +//! Message handling for point-of-sale payment notifications. +//! +//! A merchant that claims a payment for a per-order offer constructed by a point-of-sale (PoS) +//! device sends a [`PaymentNotification`] over the offer's `notification_path`. The device +//! authenticates the notification by matching the relayed `order_id` against the one in the +//! path's message context and responds with a [`NotificationAck`] or a [`NotificationNack`]. +//! Optionally, the customer's wallet may send a [`PaymentProof`] over the same path. +//! +//! See [bLIP 56](https://github.com/lightning/blips/pull/56) for more information. + +use crate::blinded_path::message::PosNotificationContext; +use crate::io; +use crate::ln::msgs::DecodeError; +use crate::onion_message::messenger::{MessageSendInstructions, Responder, ResponseInstruction}; +use crate::onion_message::packet::OnionMessageContents; +use crate::prelude::*; +use crate::types::payment::{PaymentHash, PaymentPreimage}; +use crate::util::ser::{Readable, ReadableArgs, Writeable, Writer}; + +use core::ops::Deref; + +// TLV record types for the messages, provisional until assigned in the bLIP 56 specification. +const PAYMENT_NOTIFICATION_TLV_TYPE: u64 = 65560; +const NOTIFICATION_ACK_TLV_TYPE: u64 = 65562; +const NOTIFICATION_NACK_TLV_TYPE: u64 = 65564; +const PAYMENT_PROOF_TLV_TYPE: u64 = 65566; + +/// A handler for [`PosNotificationMessage`]s. +pub trait PosNotificationMessageHandler { + /// Handles a [`PaymentNotification`] from the merchant, returning a [`NotificationAck`] or + /// [`NotificationNack`] wrapped in a [`PosNotificationMessage`] to send in response. + /// + /// This is called on the point-of-sale device. + fn handle_payment_notification( + &self, message: PaymentNotification, context: PosNotificationContext, + responder: Option, + ) -> Option<(PosNotificationMessage, ResponseInstruction)>; + + /// Handles a [`NotificationAck`] from the point-of-sale device, confirming delivery of a + /// previously sent [`PaymentNotification`]. + /// + /// This is called on the merchant node. + fn handle_notification_ack(&self, message: NotificationAck, context: PosNotificationContext); + + /// Handles a [`NotificationNack`] from the point-of-sale device, indicating a previously sent + /// [`PaymentNotification`] was rejected. + /// + /// Since the payment was already claimed when the notification was sent, a nack should be + /// reported to the node's operator for manual reconciliation rather than retried. + /// + /// This is called on the merchant node. + fn handle_notification_nack(&self, message: NotificationNack, context: PosNotificationContext); + + /// Handles a [`PaymentProof`] from the customer's wallet. + /// + /// This is called on the point-of-sale device. + fn handle_payment_proof(&self, message: PaymentProof, context: PosNotificationContext); + + /// Releases any [`PosNotificationMessage`]s that need to be sent. + fn release_pending_messages(&self) -> Vec<(PosNotificationMessage, MessageSendInstructions)> { + vec![] + } +} + +impl> PosNotificationMessageHandler + for P +{ + fn handle_payment_notification( + &self, message: PaymentNotification, context: PosNotificationContext, + responder: Option, + ) -> Option<(PosNotificationMessage, ResponseInstruction)> { + self.deref().handle_payment_notification(message, context, responder) + } + fn handle_notification_ack(&self, message: NotificationAck, context: PosNotificationContext) { + self.deref().handle_notification_ack(message, context) + } + fn handle_notification_nack(&self, message: NotificationNack, context: PosNotificationContext) { + self.deref().handle_notification_nack(message, context) + } + fn handle_payment_proof(&self, message: PaymentProof, context: PosNotificationContext) { + self.deref().handle_payment_proof(message, context) + } + fn release_pending_messages(&self) -> Vec<(PosNotificationMessage, MessageSendInstructions)> { + self.deref().release_pending_messages() + } +} + +/// Possible point-of-sale notification messages sent and received via an [`OnionMessage`]. +/// +/// [`OnionMessage`]: crate::ln::msgs::OnionMessage +#[derive(Clone, Debug)] +pub enum PosNotificationMessage { + /// A notification that a payment was claimed, sent by the merchant to the point-of-sale + /// device. + PaymentNotification(PaymentNotification), + /// A positive acknowledgement of a [`PaymentNotification`], sent by the point-of-sale device + /// to the merchant. + NotificationAck(NotificationAck), + /// A negative acknowledgement of a [`PaymentNotification`], sent by the point-of-sale device + /// to the merchant. + NotificationNack(NotificationNack), + /// A proof of payment, sent by the customer's wallet to the point-of-sale device. + PaymentProof(PaymentProof), +} + +/// A notification that the merchant claimed a payment for a per-order offer, sent to the +/// point-of-sale device over the offer's `notification_path`. +/// +/// The device must only trust the notification if [`Self::order_id`] matches the `order_id` in +/// the notification path's message context, since anyone holding the offer can send messages over +/// the path but only the merchant can recover the `order_id` from the offer's `payment_token`. +#[derive(Clone, Debug)] +pub struct PaymentNotification { + /// The payment hash of the claimed payment, from the invoice the merchant issued. + pub payment_hash: PaymentHash, + /// The payment preimage released when claiming the payment. + pub payment_preimage: PaymentPreimage, + /// The amount claimed, in millisatoshis. + pub amount_msats: u64, + /// The per-order secret recovered from the offer's `payment_token`. + pub order_id: Vec, +} + +/// A positive acknowledgement of a [`PaymentNotification`], sent by the point-of-sale device once +/// the notification has been authenticated. +#[derive(Clone, Debug)] +pub struct NotificationAck {} + +/// A negative acknowledgement of a [`PaymentNotification`], carrying the reason the notification +/// was rejected. +#[derive(Clone, Debug)] +pub struct NotificationNack { + /// The reason the notification was rejected. + pub reason: NackReason, +} + +/// The reason a [`PaymentNotification`] was rejected with a [`NotificationNack`]. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum NackReason { + /// No order state matching the notification was found. + UnknownOrder, + /// The order expired before the notification arrived. + OrderExpired, + /// The relayed `order_id` did not match the one in the notification path's message context, + /// indicating a possible forgery or a corrupted `payment_token`. + OrderIdMismatch, + /// The notified amount was less than the order amount. + AmountInsufficient, + /// The notified preimage did not hash to the notified payment hash. + InvalidPreimage, + /// A reason code not known to this version of LDK. + Unknown(u8), +} + +impl NackReason { + fn to_byte(&self) -> u8 { + match self { + Self::UnknownOrder => 0, + Self::OrderExpired => 1, + Self::OrderIdMismatch => 2, + Self::AmountInsufficient => 3, + Self::InvalidPreimage => 4, + Self::Unknown(reason) => *reason, + } + } + + fn from_byte(byte: u8) -> Self { + match byte { + 0 => Self::UnknownOrder, + 1 => Self::OrderExpired, + 2 => Self::OrderIdMismatch, + 3 => Self::AmountInsufficient, + 4 => Self::InvalidPreimage, + reason => Self::Unknown(reason), + } + } +} + +impl Writeable for NackReason { + fn write(&self, w: &mut W) -> Result<(), io::Error> { + self.to_byte().write(w) + } +} + +impl Readable for NackReason { + fn read(r: &mut R) -> Result { + Ok(Self::from_byte(Readable::read(r)?)) + } +} + +/// A proof of payment for a per-order offer, sent by the customer's wallet to the point-of-sale +/// device over the offer's `notification_path`. +#[derive(Clone, Debug)] +pub struct PaymentProof { + /// A serialized BOLT 12 payer proof, as defined in + /// [bolts#1295](https://github.com/lightning/bolts/pull/1295). + /// + /// This is treated as opaque bytes; verifying it per the bolts#1295 reader requirements is the + /// responsibility of the consumer. + pub proof: Vec, +} + +impl_ser_tlv_based!(PaymentNotification, { + (0, payment_hash, required), + (2, payment_preimage, required), + (4, amount_msats, required), + (6, order_id, required_vec), +}); + +impl_ser_tlv_based!(NotificationAck, {}); + +impl_ser_tlv_based!(NotificationNack, { + (0, reason, required), +}); + +impl_ser_tlv_based!(PaymentProof, { + (0, proof, required_vec), +}); + +impl PosNotificationMessage { + /// Returns whether `tlv_type` corresponds to a TLV record for point-of-sale notification + /// messages. + pub fn is_known_type(tlv_type: u64) -> bool { + match tlv_type { + PAYMENT_NOTIFICATION_TLV_TYPE + | NOTIFICATION_ACK_TLV_TYPE + | NOTIFICATION_NACK_TLV_TYPE + | PAYMENT_PROOF_TLV_TYPE => true, + _ => false, + } + } +} + +impl OnionMessageContents for PosNotificationMessage { + fn tlv_type(&self) -> u64 { + match self { + Self::PaymentNotification(_) => PAYMENT_NOTIFICATION_TLV_TYPE, + Self::NotificationAck(_) => NOTIFICATION_ACK_TLV_TYPE, + Self::NotificationNack(_) => NOTIFICATION_NACK_TLV_TYPE, + Self::PaymentProof(_) => PAYMENT_PROOF_TLV_TYPE, + } + } + #[cfg(c_bindings)] + fn msg_type(&self) -> String { + match &self { + Self::PaymentNotification(_) => "Payment Notification".to_string(), + Self::NotificationAck(_) => "Notification Ack".to_string(), + Self::NotificationNack(_) => "Notification Nack".to_string(), + Self::PaymentProof(_) => "Payment Proof".to_string(), + } + } + #[cfg(not(c_bindings))] + fn msg_type(&self) -> &'static str { + match &self { + Self::PaymentNotification(_) => "Payment Notification", + Self::NotificationAck(_) => "Notification Ack", + Self::NotificationNack(_) => "Notification Nack", + Self::PaymentProof(_) => "Payment Proof", + } + } +} + +impl Writeable for PosNotificationMessage { + fn write(&self, w: &mut W) -> Result<(), io::Error> { + match self { + Self::PaymentNotification(message) => message.write(w), + Self::NotificationAck(message) => message.write(w), + Self::NotificationNack(message) => message.write(w), + Self::PaymentProof(message) => message.write(w), + } + } +} + +impl ReadableArgs for PosNotificationMessage { + fn read(r: &mut R, tlv_type: u64) -> Result { + match tlv_type { + PAYMENT_NOTIFICATION_TLV_TYPE => Ok(Self::PaymentNotification(Readable::read(r)?)), + NOTIFICATION_ACK_TLV_TYPE => Ok(Self::NotificationAck(Readable::read(r)?)), + NOTIFICATION_NACK_TLV_TYPE => Ok(Self::NotificationNack(Readable::read(r)?)), + PAYMENT_PROOF_TLV_TYPE => Ok(Self::PaymentProof(Readable::read(r)?)), + _ => Err(DecodeError::InvalidValue), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::io::Cursor; + + fn payment_notification() -> PaymentNotification { + PaymentNotification { + payment_hash: PaymentHash([1; 32]), + payment_preimage: PaymentPreimage([2; 32]), + amount_msats: 100_000, + order_id: vec![3; 16], + } + } + + #[test] + fn payment_notification_round_trips() { + let message = PosNotificationMessage::PaymentNotification(payment_notification()); + let bytes = message.encode(); + let decoded = + PosNotificationMessage::read(&mut Cursor::new(&bytes), PAYMENT_NOTIFICATION_TLV_TYPE) + .unwrap(); + match decoded { + PosNotificationMessage::PaymentNotification(decoded) => { + assert_eq!(decoded.payment_hash, PaymentHash([1; 32])); + assert_eq!(decoded.payment_preimage, PaymentPreimage([2; 32])); + assert_eq!(decoded.amount_msats, 100_000); + assert_eq!(decoded.order_id, vec![3; 16]); + }, + _ => panic!("unexpected message"), + } + } + + #[test] + fn notification_nack_reason_round_trips() { + for reason in [ + NackReason::UnknownOrder, + NackReason::OrderExpired, + NackReason::OrderIdMismatch, + NackReason::AmountInsufficient, + NackReason::InvalidPreimage, + NackReason::Unknown(57), + ] { + let message = PosNotificationMessage::NotificationNack(NotificationNack { reason }); + let bytes = message.encode(); + let decoded = + PosNotificationMessage::read(&mut Cursor::new(&bytes), NOTIFICATION_NACK_TLV_TYPE) + .unwrap(); + match decoded { + PosNotificationMessage::NotificationNack(decoded) => { + assert_eq!(decoded.reason, reason); + }, + _ => panic!("unexpected message"), + } + } + } + + #[test] + fn rejects_unknown_tlv_type() { + let message = PosNotificationMessage::NotificationAck(NotificationAck {}); + let bytes = message.encode(); + assert!(PosNotificationMessage::read(&mut Cursor::new(&bytes), 65568).is_err()); + } + + #[test] + fn known_types_match_constants() { + assert!(PosNotificationMessage::is_known_type(PAYMENT_NOTIFICATION_TLV_TYPE)); + assert!(PosNotificationMessage::is_known_type(NOTIFICATION_ACK_TLV_TYPE)); + assert!(PosNotificationMessage::is_known_type(NOTIFICATION_NACK_TLV_TYPE)); + assert!(PosNotificationMessage::is_known_type(PAYMENT_PROOF_TLV_TYPE)); + assert!(!PosNotificationMessage::is_known_type(65568)); + } +} From 1f6a19e1063f86cea810ddd7ba05e10a69dd0ab6 Mon Sep 17 00:00:00 2001 From: Vincenzo Palazzo Date: Wed, 10 Jun 2026 19:00:03 +0200 Subject: [PATCH 5/6] ln: Implement bLIP 56 notification delivery and handling On the merchant side, an invoice request arriving over a template's DelegatedInvoiceRequest context is verified against the offer's payment_token, rejecting it if the token or notification_path is missing, fails to decrypt, or its sealed amount or description do not match the mirrored offer. The recovered order_id, the notification paths, and the order expiry are embedded in the payment context's payment_metadata, using keys from the LDK-reserved range. When the payment is claimed, a payment_notification is queued to the device over the offer's notification_path and retried on timer ticks until acknowledged or the order's expiry plus a grace period has passed, persisting across restarts. On the point-of-sale side, notifications are validated against the notification path's message context per the bLIP 56 acceptance rules and answered with an ack or a reason-coded nack. Authenticated notifications surface as Event::PaymentNotificationReceived, and customer payment proofs as Event::PaymentProofReceived, left unverified for the consumer until BOLT 12 payer proofs are available. ChannelManager grows create_pos_delegation_template, create_pos_order_offer, and send_payment_proof to drive the three roles of the protocol. Co-Authored-By: Claude Fable 5 --- lightning/src/events/mod.rs | 122 ++++++ lightning/src/ln/channelmanager.rs | 224 ++++++++++- lightning/src/ln/functional_test_utils.rs | 4 +- lightning/src/offers/flow.rs | 434 +++++++++++++++++++++- 4 files changed, 778 insertions(+), 6 deletions(-) diff --git a/lightning/src/events/mod.rs b/lightning/src/events/mod.rs index 4853c83b19c..0e2a2214db6 100644 --- a/lightning/src/events/mod.rs +++ b/lightning/src/events/mod.rs @@ -2011,6 +2011,60 @@ pub enum Event { /// [`ChannelManager::funding_transaction_signed`]: crate::ln::channelmanager::ChannelManager::funding_transaction_signed unsigned_transaction: Transaction, }, + /// A merchant's `payment_notification` for a per-order offer constructed by this node as a + /// point-of-sale device was received and authenticated, meaning the merchant claimed the + /// payment for the order. A `notification_ack` was sent in response. + /// + /// The notification was authenticated by matching the relayed `order_id` against the one in + /// the offer's notification path context, and the preimage and amount were verified. Handle + /// duplicates idempotently: the merchant retries notifications until acknowledged, so a + /// second event for an already-confirmed order may occur and must not record the payment + /// twice. + /// + /// See [bLIP 56](https://github.com/lightning/blips/pull/56) for more information. + /// + /// # Failure Behavior and Persistence + /// This event will eventually be replayed after failures-to-handle (i.e., the event handler + /// returning `Err(ReplayEvent ())`) and will be persisted across restarts. + PaymentNotificationReceived { + /// The per-order secret identifying the order, as generated when constructing the + /// per-order offer. + order_id: Vec, + /// The amount claimed by the merchant, in millisatoshis. At least the order amount. + amount_msats: u64, + /// The payment hash of the claimed payment. + payment_hash: PaymentHash, + /// The payment preimage released by the merchant when claiming the payment. + payment_preimage: PaymentPreimage, + /// The description set on the per-order offer, if any. + description: Option, + }, + /// A customer's `payment_proof` for a per-order offer constructed by this node as a + /// point-of-sale device was received over the offer's notification path. + /// + /// The proof is a serialized BOLT 12 payer proof, as defined in + /// [bolts#1295](https://github.com/lightning/bolts/pull/1295). It is provided as opaque bytes + /// and has NOT been verified; verifying it per the bolts#1295 reader requirements, including + /// that the mirrored `payment_token` matches the one issued for this order, is the handler's + /// responsibility. + /// + /// See [bLIP 56](https://github.com/lightning/blips/pull/56) for more information. + /// + /// # Failure Behavior and Persistence + /// This event will eventually be replayed after failures-to-handle (i.e., the event handler + /// returning `Err(ReplayEvent ())`) and will be persisted across restarts. + PaymentProofReceived { + /// The per-order secret identifying the order, as generated when constructing the + /// per-order offer. + order_id: Vec, + /// The serialized, unverified BOLT 12 payer proof. + proof: Vec, + /// The amount in millisatoshis set on the per-order offer, against which the proof's + /// `invoice_amount` should be checked. + amount_msats: u64, + /// The description set on the per-order offer, if any. + description: Option, + }, } impl Writeable for Event { @@ -2522,6 +2576,36 @@ impl Writeable for Event { (13, contribution, option), }); }, + &Event::PaymentNotificationReceived { + ref order_id, + ref amount_msats, + ref payment_hash, + ref payment_preimage, + ref description, + } => { + 53u8.write(writer)?; + write_tlv_fields!(writer, { + (1, order_id, required_vec), + (3, amount_msats, required), + (5, payment_hash, required), + (7, payment_preimage, required), + (9, description, option), + }); + }, + &Event::PaymentProofReceived { + ref order_id, + ref proof, + ref amount_msats, + ref description, + } => { + 55u8.write(writer)?; + write_tlv_fields!(writer, { + (1, order_id, required_vec), + (3, proof, required_vec), + (5, amount_msats, required), + (7, description, option), + }); + }, // Note that, going forward, all new events must only write data inside of // `write_tlv_fields`. Versions 0.0.101+ will ignore odd-numbered events that write // data via `write_tlv_fields`. @@ -3176,6 +3260,44 @@ impl MaybeReadable for Event { }; f() }, + 53u8 => { + let mut f = || { + _init_and_read_len_prefixed_tlv_fields!(reader, { + (1, order_id, required_vec), + (3, amount_msats, required), + (5, payment_hash, required), + (7, payment_preimage, required), + (9, description, option), + }); + + Ok(Some(Event::PaymentNotificationReceived { + order_id, + amount_msats: amount_msats.0.unwrap(), + payment_hash: payment_hash.0.unwrap(), + payment_preimage: payment_preimage.0.unwrap(), + description, + })) + }; + f() + }, + 55u8 => { + let mut f = || { + _init_and_read_len_prefixed_tlv_fields!(reader, { + (1, order_id, required_vec), + (3, proof, required_vec), + (5, amount_msats, required), + (7, description, option), + }); + + Ok(Some(Event::PaymentProofReceived { + order_id, + proof, + amount_msats: amount_msats.0.unwrap(), + description, + })) + }; + f() + }, // Versions prior to 0.0.100 did not ignore odd types, instead returning InvalidValue. // Version 0.0.100 failed to properly ignore odd types, possibly resulting in corrupt // reads. diff --git a/lightning/src/ln/channelmanager.rs b/lightning/src/ln/channelmanager.rs index 6398613a762..80913b0c967 100644 --- a/lightning/src/ln/channelmanager.rs +++ b/lightning/src/ln/channelmanager.rs @@ -24,6 +24,7 @@ use bitcoin::network::Network; use bitcoin::transaction::Transaction; use bitcoin::hash_types::{BlockHash, Txid}; +use bitcoin::hashes::cmp::fixed_time_eq; use bitcoin::hashes::hmac::Hmac; use bitcoin::hashes::sha256::Hash as Sha256; use bitcoin::hashes::{Hash, HashEngine, HmacEngine}; @@ -34,6 +35,7 @@ use bitcoin::{secp256k1, Sequence, SignedAmount}; use crate::blinded_path::message::{ AsyncPaymentsContext, BlindedMessagePath, MessageForwardNode, OffersContext, + PosNotificationContext, }; use crate::blinded_path::payment::{AsyncBolt12OfferContext, Bolt12OfferContext, PaymentContext}; use crate::blinded_path::NodeIdLookUp; @@ -94,7 +96,9 @@ use crate::ln::outbound_payment::{ }; use crate::ln::types::ChannelId; use crate::offers::async_receive_offer_cache::AsyncReceiveOfferCache; -use crate::offers::flow::{HeldHtlcReplyPath, InvreqResponseInstructions, OffersMessageFlow}; +use crate::offers::flow::{ + HeldHtlcReplyPath, InvreqResponseInstructions, OffersMessageFlow, PendingPosNotification, +}; use crate::offers::invoice::{Bolt12Invoice, UnsignedBolt12Invoice}; use crate::offers::invoice_error::InvoiceError; use crate::offers::invoice_request::{InvoiceRequest, InvoiceRequestVerifiedFromOffer}; @@ -112,6 +116,10 @@ use crate::onion_message::messenger::{ MessageRouter, MessageSendInstructions, Responder, ResponseInstruction, }; use crate::onion_message::offers::{OffersMessage, OffersMessageHandler}; +use crate::onion_message::pos_notification::{ + NackReason, NotificationAck, NotificationNack, PaymentNotification, PaymentProof, + PosNotificationMessage, PosNotificationMessageHandler, +}; use crate::routing::gossip::NodeId; use crate::routing::router::{ BlindedTail, FixedRouter, InFlightHtlcs, Path, Payee, PaymentParameters, Route, @@ -9097,6 +9105,7 @@ impl< .remove_stale_payments(duration_since_epoch, &self.pending_events); self.check_refresh_async_receive_offer_cache(true); + self.flow.requeue_pending_pos_notifications(); if self.check_free_holding_cells() { // While we try to ensure we clear holding cells immediately, its possible we miss @@ -10453,6 +10462,29 @@ This indicates a bug inside LDK. Please report this error at https://github.com/ durable_preimage_channel, }) = payment { + if let events::PaymentPurpose::Bolt12OfferPayment { + payment_preimage: Some(payment_preimage), + payment_context, + .. + } = &purpose + { + if let Some(metadata) = &payment_context.payment_metadata { + if let Some((order_id, notification_paths, order_expiry)) = + OffersMessageFlow::::pos_order_data_from_payment_metadata( + metadata, + ) { + self.flow.enqueue_pos_payment_notification( + order_id, + notification_paths, + amount_msat, + payment_hash, + *payment_preimage, + order_expiry, + ); + } + } + } + let event = events::Event::PaymentClaimed { payment_hash, purpose, @@ -14893,6 +14925,61 @@ impl< Ok(()) } + /// Creates an [`Offer`] template for handing to a point-of-sale device, which constructs + /// per-order offers from it. When a payment for such a per-order offer is claimed, a payment + /// notification is automatically delivered to the device over the offer's + /// `notification_path`, with retries on each timer tick until acknowledged. + /// + /// See [`OffersMessageFlow::create_pos_delegation_template`] for details and + /// [bLIP 56](https://github.com/lightning/blips/pull/56) for the protocol. + pub fn create_pos_delegation_template( + &self, absolute_expiry: Option, + ) -> Result { + self.flow.create_pos_delegation_template( + &self.entropy_source, + self.get_peers_for_blinded_path(), + absolute_expiry, + ) + } + + /// Constructs a per-order [`Offer`] from a merchant-provided point-of-sale template, for + /// presenting to a customer, returning the offer along with the generated per-order + /// `order_id`. Once the merchant claims the order's payment, its notification is surfaced via + /// [`Event::PaymentNotificationReceived`]. + /// + /// See [`OffersMessageFlow::create_pos_order_offer`] for details and + /// [bLIP 56](https://github.com/lightning/blips/pull/56) for the protocol. + /// + /// [`Event::PaymentNotificationReceived`]: crate::events::Event::PaymentNotificationReceived + pub fn create_pos_order_offer( + &self, template: &Offer, merchant_node_id: PublicKey, amount_msats: u64, + description: Option, order_absolute_expiry: Option, + ) -> Result<(Offer, Vec), Bolt12SemanticError> { + self.flow.create_pos_order_offer( + template, + merchant_node_id, + amount_msats, + description, + order_absolute_expiry, + &self.entropy_source, + ) + } + + /// Sends a `payment_proof` onion message to the point-of-sale device behind the given offer's + /// `notification_path`s, proving that this node paid the offer. `proof` is a serialized + /// BOLT 12 payer proof, as defined in + /// [bolts#1295](https://github.com/lightning/bolts/pull/1295). + /// + /// Errors if the offer does not contain any `notification_path`s. + /// + /// See [bLIP 56](https://github.com/lightning/blips/pull/56) for more information. + pub fn send_payment_proof( + &self, offer: &Offer, proof: Vec, + ) -> Result<(), Bolt12SemanticError> { + let _persistence_guard = PersistenceNotifierGuard::notify_on_drop(self); + self.flow.enqueue_payment_proof(offer, proof) + } + /// Pays for an [`Offer`] using the given parameters by creating an [`InvoiceRequest`] and /// enqueuing it to be sent via an onion message. [`ChannelManager`] will pay the actual /// [`Bolt12Invoice`] once it is received. @@ -17725,6 +17812,131 @@ impl< } } +impl< + M: chain::Watch, + T: BroadcasterInterface, + ES: EntropySource, + NS: NodeSigner, + SP: SignerProvider, + F: FeeEstimator, + R: Router, + MR: MessageRouter, + L: Logger, + > PosNotificationMessageHandler for ChannelManager +{ + fn handle_payment_notification( + &self, message: PaymentNotification, context: PosNotificationContext, + responder: Option, + ) -> Option<(PosNotificationMessage, ResponseInstruction)> { + let (order_id, amount_msats, description, order_absolute_expiry) = match context { + PosNotificationContext::InboundOrder { + order_id, + amount_msats, + description, + order_absolute_expiry, + } => (order_id, amount_msats, description, order_absolute_expiry), + PosNotificationContext::OutboundNotification { .. } => { + return responder.map(|responder| { + let nack = NotificationNack { reason: NackReason::UnknownOrder }; + (PosNotificationMessage::NotificationNack(nack), responder.respond()) + }); + }, + }; + + // The path-level authentication of the notification path guarantees the context was + // created by us, and only the merchant can recover the order_id from the offer's + // payment_token, so a matching order_id authenticates the notification as + // merchant-originated. Without it, the amount cannot be trusted. + let order_id_matches = + order_id.len() == message.order_id.len() && fixed_time_eq(&order_id, &message.order_id); + let reason = if !order_id_matches { + Some(NackReason::OrderIdMismatch) + } else if order_absolute_expiry.map_or(false, |expiry| self.duration_since_epoch() > expiry) + { + Some(NackReason::OrderExpired) + } else if message.amount_msats < amount_msats { + Some(NackReason::AmountInsufficient) + } else if PaymentHash::from(message.payment_preimage) != message.payment_hash { + Some(NackReason::InvalidPreimage) + } else { + None + }; + + if let Some(reason) = reason { + return responder.map(|responder| { + let nack = NotificationNack { reason }; + (PosNotificationMessage::NotificationNack(nack), responder.respond()) + }); + } + + // The merchant retries notifications until acknowledged, so consumers of this event must + // handle duplicates for an already-confirmed order idempotently. + { + let _persistence_guard = PersistenceNotifierGuard::notify_on_drop(self); + let mut pending_events = self.pending_events.lock().unwrap(); + pending_events.push_back(( + events::Event::PaymentNotificationReceived { + order_id, + amount_msats: message.amount_msats, + payment_hash: message.payment_hash, + payment_preimage: message.payment_preimage, + description, + }, + None, + )); + } + + responder.map(|responder| { + let ack = NotificationAck {}; + (PosNotificationMessage::NotificationAck(ack), responder.respond()) + }) + } + + fn handle_notification_ack(&self, _message: NotificationAck, context: PosNotificationContext) { + if let PosNotificationContext::OutboundNotification { payment_hash } = context { + let _persistence_guard = PersistenceNotifierGuard::notify_on_drop(self); + self.flow.resolve_pending_pos_notification(&payment_hash); + } + } + + fn handle_notification_nack(&self, message: NotificationNack, context: PosNotificationContext) { + if let PosNotificationContext::OutboundNotification { payment_hash } = context { + log_error!( + self.logger, + "PoS payment notification for payment {} was rejected with reason {:?}; the \ + payment was already claimed, so manual reconciliation may be required", + payment_hash, + message.reason + ); + let _persistence_guard = PersistenceNotifierGuard::notify_on_drop(self); + self.flow.resolve_pending_pos_notification(&payment_hash); + } + } + + fn handle_payment_proof(&self, message: PaymentProof, context: PosNotificationContext) { + if let PosNotificationContext::InboundOrder { + order_id, amount_msats, description, .. + } = context + { + let _persistence_guard = PersistenceNotifierGuard::notify_on_drop(self); + let mut pending_events = self.pending_events.lock().unwrap(); + pending_events.push_back(( + events::Event::PaymentProofReceived { + order_id, + proof: message.proof, + amount_msats, + description, + }, + None, + )); + } + } + + fn release_pending_messages(&self) -> Vec<(PosNotificationMessage, MessageSendInstructions)> { + self.flow.release_pending_pos_notification_messages() + } +} + impl< M: chain::Watch, T: BroadcasterInterface, @@ -18508,6 +18720,7 @@ impl< } } + let pending_pos_notifications = self.flow.pending_pos_notifications(); write_tlv_fields!(writer, { (1, pending_outbound_payments_no_retry, required), (2, pending_intercepted_htlcs, option), @@ -18527,6 +18740,7 @@ impl< (19, peer_storage_dir, optional_vec), (21, WithoutLength(&self.flow.writeable_async_receive_offer_cache()), required), (23, self.best_block.read().unwrap().previous_blocks, required), + (25, pending_pos_notifications, optional_vec), }); // Remove the SpliceNegotiationFailed and DiscardFunding events added earlier. @@ -18613,6 +18827,7 @@ pub(super) struct ChannelManagerData { in_flight_monitor_updates: HashMap<(PublicKey, ChannelId), Vec>, peer_storage_dir: Vec<(PublicKey, Vec)>, async_receive_offer_cache: AsyncReceiveOfferCache, + pending_pos_notifications: Vec, // Marked `_legacy` because in versions > 0.2 we are taking steps to remove the requirement of // regularly persisting the `ChannelManager` and instead rebuild the set of HTLC forwards from // `Channel{Monitor}` data. @@ -18804,6 +19019,7 @@ impl<'a, ES: EntropySource, SP: SignerProvider, L: Logger> let mut inbound_payment_id_secret = None; let mut peer_storage_dir: Option)>> = None; let mut async_receive_offer_cache: AsyncReceiveOfferCache = AsyncReceiveOfferCache::new(); + let mut pending_pos_notifications = None; let mut best_block_previous_blocks = None; read_tlv_fields!(reader, { (1, pending_outbound_payments_no_retry, option), @@ -18824,6 +19040,7 @@ impl<'a, ES: EntropySource, SP: SignerProvider, L: Logger> (19, peer_storage_dir, optional_vec), (21, async_receive_offer_cache, (default_value, async_receive_offer_cache)), (23, best_block_previous_blocks, option), + (25, pending_pos_notifications, optional_vec), }); // Merge legacy pending_outbound_payments fields into a single HashMap. @@ -18944,6 +19161,7 @@ impl<'a, ES: EntropySource, SP: SignerProvider, L: Logger> in_flight_monitor_updates: in_flight_monitor_updates.unwrap_or_default(), peer_storage_dir: peer_storage_dir.unwrap_or_default(), async_receive_offer_cache, + pending_pos_notifications: pending_pos_notifications.unwrap_or_default(), version, }) } @@ -19245,6 +19463,7 @@ impl< mut in_flight_monitor_updates, peer_storage_dir, async_receive_offer_cache, + pending_pos_notifications, version: _version, } = data; @@ -20450,7 +20669,8 @@ impl< args.message_router, args.logger.clone(), ) - .with_async_payments_offers_cache(async_receive_offer_cache); + .with_async_payments_offers_cache(async_receive_offer_cache) + .with_pending_pos_notifications(pending_pos_notifications); let channel_manager = ChannelManager { chain_hash, diff --git a/lightning/src/ln/functional_test_utils.rs b/lightning/src/ln/functional_test_utils.rs index 9697d249579..7b7ec9eaf8e 100644 --- a/lightning/src/ln/functional_test_utils.rs +++ b/lightning/src/ln/functional_test_utils.rs @@ -533,7 +533,7 @@ type TestOnionMessenger<'chan_man, 'node_cfg, 'chan_mon_cfg> = OnionMessenger< &'chan_man TestChannelManager<'node_cfg, 'chan_mon_cfg>, &'chan_man TestChannelManager<'node_cfg, 'chan_mon_cfg>, IgnoringMessageHandler, - IgnoringMessageHandler, + &'chan_man TestChannelManager<'node_cfg, 'chan_mon_cfg>, IgnoringMessageHandler, >; @@ -4799,7 +4799,7 @@ pub fn create_network<'a, 'b: 'a, 'c: 'b>( &chan_mgrs[i], &chan_mgrs[i], IgnoringMessageHandler {}, - IgnoringMessageHandler {}, + &chan_mgrs[i], IgnoringMessageHandler {}, ); let gossip_sync = P2PGossipSync::new(cfgs[i].network_graph.as_ref(), None, cfgs[i].logger); diff --git a/lightning/src/offers/flow.rs b/lightning/src/offers/flow.rs index bdc3475b554..109d006bddd 100644 --- a/lightning/src/offers/flow.rs +++ b/lightning/src/offers/flow.rs @@ -21,6 +21,7 @@ use bitcoin::secp256k1::{self, PublicKey, Secp256k1}; use crate::blinded_path::message::{ AsyncPaymentsContext, BlindedMessagePath, MessageContext, MessageForwardNode, OffersContext, + PosNotificationContext, }; use crate::blinded_path::payment::{ AsyncBolt12OfferContext, BlindedPaymentPath, Bolt12OfferContext, Bolt12RefundContext, @@ -46,7 +47,9 @@ use crate::offers::invoice_request::{ use crate::offers::nonce::Nonce; use crate::offers::offer::{Amount, DerivedMetadata, Offer, OfferBuilder}; use crate::offers::parse::Bolt12SemanticError; +use crate::offers::payment_token::{PaymentTokenPayload, MIN_ORDER_ID_LEN}; use crate::offers::refund::{Refund, RefundBuilder}; +use crate::offers::signer; use crate::offers::static_invoice::{StaticInvoice, StaticInvoiceBuilder}; use crate::onion_message::async_payments::{ AsyncPaymentsMessage, HeldHtlcAvailable, OfferPaths, OfferPathsRequest, ServeStaticInvoice, @@ -57,12 +60,15 @@ use crate::onion_message::messenger::{ }; use crate::onion_message::offers::OffersMessage; use crate::onion_message::packet::OnionMessageContents; +use crate::onion_message::pos_notification::{ + PaymentNotification, PaymentProof, PosNotificationMessage, +}; use crate::routing::router::Router; use crate::sign::{EntropySource, ReceiveAuthKey}; use crate::sync::{Mutex, RwLock}; -use crate::types::payment::{PaymentHash, PaymentSecret}; +use crate::types::payment::{PaymentHash, PaymentPreimage, PaymentSecret}; use crate::util::logger::Logger; -use crate::util::ser::Writeable; +use crate::util::ser::{FixedLengthReader, LengthReadable, Readable, WithoutLength, Writeable}; /// A BOLT12 offers code and flow utility provider, which facilitates /// BOLT12 builder generation and onion message handling. @@ -90,6 +96,10 @@ pub struct OffersMessageFlow { pending_async_payments_messages: Mutex>, async_receive_offer_cache: Mutex, + pending_pos_notification_messages: + Mutex>, + pending_pos_notifications: Mutex>, + logger: L, } @@ -119,6 +129,9 @@ impl OffersMessageFlow { async_receive_offer_cache: Mutex::new(AsyncReceiveOfferCache::new()), + pending_pos_notification_messages: Mutex::new(Vec::new()), + pending_pos_notifications: Mutex::new(Vec::new()), + logger, } } @@ -390,6 +403,64 @@ fn enqueue_onion_message_with_reply_paths( }); } +/// The `payment_metadata` key under which the `order_id` recovered from a point-of-sale offer's +/// `payment_token` is stored in the payment context of the offer's blinded payment paths. +/// +/// See [bLIP 56](https://github.com/lightning/blips/pull/56) for more information. +pub const POS_ORDER_ID_PAYMENT_METADATA_KEY: u64 = 128; + +/// The `payment_metadata` key under which a point-of-sale offer's `notification_path`s are stored +/// in the payment context of the offer's blinded payment paths, serialized as a +/// `WithoutLength>`. +/// +/// See [bLIP 56](https://github.com/lightning/blips/pull/56) for more information. +pub const POS_NOTIFICATION_PATHS_PAYMENT_METADATA_KEY: u64 = 129; + +/// The `payment_metadata` key under which a point-of-sale offer's `offer_absolute_expiry` is +/// stored in the payment context of the offer's blinded payment paths, serialized as a big-endian +/// `u64` of seconds since the Unix epoch. Used to bound notification retries. +/// +/// See [bLIP 56](https://github.com/lightning/blips/pull/56) for more information. +pub const POS_ORDER_EXPIRY_PAYMENT_METADATA_KEY: u64 = 130; + +/// The grace period past an order's expiry during which payment notifications are still retried. +const POS_NOTIFICATION_GRACE_PERIOD: Duration = Duration::from_secs(2 * 60 * 60); + +/// How long payment notifications are retried for orders without an expiry. +const DEFAULT_PENDING_POS_NOTIFICATION_LIFETIME: Duration = Duration::from_secs(24 * 60 * 60); + +/// A payment notification for a point-of-sale offer that has not been acknowledged by the +/// point-of-sale device yet. Notifications are retried until acknowledged or until +/// [`Self::retry_until`] has passed, since onion messages are best-effort. +/// +/// See [bLIP 56](https://github.com/lightning/blips/pull/56) for more information. +#[derive(Clone, Debug)] +pub struct PendingPosNotification { + /// The payment hash of the claimed payment. + pub payment_hash: PaymentHash, + /// The payment preimage released when claiming the payment. + pub payment_preimage: PaymentPreimage, + /// The amount claimed, in millisatoshis. + pub amount_msats: u64, + /// The per-order secret recovered from the offer's `payment_token`. + pub order_id: Vec, + /// The blinded paths to the point-of-sale device from the offer's `notification_path`. + pub notification_paths: Vec, + /// The time as duration since the Unix epoch at which retrying is abandoned. An undeliverable + /// notification means this node holds a payment the point-of-sale device never confirmed, so + /// abandonment is logged for manual reconciliation. + pub retry_until: Duration, +} + +impl_ser_tlv_based!(PendingPosNotification, { + (0, payment_hash, required), + (2, payment_preimage, required), + (4, amount_msats, required), + (6, order_id, required_vec), + (8, notification_paths, required_vec), + (10, retry_until, required), +}); + /// Instructions for how to respond to an `InvoiceRequest`. pub enum InvreqResponseInstructions { /// We are the recipient of this payment, and a [`Bolt12Invoice`] should be sent in response to @@ -455,6 +526,11 @@ impl OffersMessageFlow { let nonce = match context { None if invoice_request.metadata().is_some() => None, Some(OffersContext::InvoiceRequest { nonce, payment_metadata: _ }) => Some(nonce), + Some(OffersContext::DelegatedInvoiceRequest { nonce }) => { + let invoice_request = invoice_request.verify_for_delegation(nonce, expanded_key)?; + self.verify_delegated_payment_token(&invoice_request)?; + return Ok(InvreqResponseInstructions::SendInvoice(invoice_request)); + }, Some(OffersContext::StaticInvoiceRequested { recipient_id, invoice_slot, @@ -484,6 +560,46 @@ impl OffersMessageFlow { Ok(InvreqResponseInstructions::SendInvoice(invoice_request)) } + /// Verifies the `payment_token` of an offer derived from one of our point-of-sale templates, + /// as mirrored in the given verified [`InvoiceRequest`]. + /// + /// Per [bLIP 56], the request must be rejected if the `notification_path` or `payment_token` + /// is missing, the token fails to decrypt, or the token's sealed amount or description do not + /// match the mirrored offer fields, since no deliverable notification could be produced for + /// the resulting payment. + /// + /// [bLIP 56]: https://github.com/lightning/blips/pull/56 + fn verify_delegated_payment_token( + &self, invoice_request: &InvoiceRequestVerifiedFromOffer, + ) -> Result<(), ()> { + let request = match invoice_request { + InvoiceRequestVerifiedFromOffer::DerivedKeys(request) => request, + InvoiceRequestVerifiedFromOffer::ExplicitKeys(_) => return Err(()), + }; + + if request.notification_paths().is_empty() { + return Err(()); + } + + let token = request.payment_token().ok_or(())?; + let payload = PaymentTokenPayload::decrypt(&request.keys.0.secret_key(), token)?; + + let offer_amount_msats = match request.amount() { + Some(Amount::Bitcoin { amount_msats }) => amount_msats, + _ => return Err(()), + }; + if payload.amount_msats != offer_amount_msats { + return Err(()); + } + + let offer_description = request.description().map(|description| description.to_string()); + if payload.description != offer_description { + return Err(()); + } + + Ok(()) + } + /// Verifies a [`Bolt12Invoice`] using the provided [`OffersContext`] or the invoice's payer /// metadata, returning the corresponding [`PaymentId`] if successful. /// @@ -633,6 +749,116 @@ impl OffersMessageFlow { .map(|(builder, _)| builder) } + /// Creates an [`Offer`] template for handing to a point-of-sale device, which constructs + /// per-order offers from it by adding an amount, description, expiry, `notification_path`s, + /// and a `payment_token` sealed to the template's signing pubkey. + /// + /// The template sets the `payment_notifications` feature bit to advertise that, when a + /// payment for a per-order offer is claimed, a payment notification will be delivered to the + /// device over the offer's `notification_path`. Corresponding [`InvoiceRequest`]s are + /// verified by [`Self::verify_invoice_request`] by re-deriving the template's signing keys + /// from the blinded path context, independently of the offer's TLV records, which the device + /// modifies. + /// + /// The template MAY be refreshed periodically to rotate the blinded paths it contains. + /// + /// See [bLIP 56](https://github.com/lightning/blips/pull/56) for more information. + /// + /// # Errors + /// + /// Returns an error if the parameterized [`MessageRouter`] is unable to create a blinded path + /// for the offer. + pub fn create_pos_delegation_template( + &self, entropy_source: ES, peers: Vec, + absolute_expiry: Option, + ) -> Result { + let expanded_key = &self.inbound_payment_key; + let entropy = &entropy_source; + + let nonce = Nonce::from_entropy_source(entropy); + let context = MessageContext::Offers(OffersContext::DelegatedInvoiceRequest { nonce }); + let keys = signer::derive_keys_for_delegation(nonce, expanded_key); + + let mut builder = OfferBuilder::new(keys.public_key()) + .chain_hash(self.chain_hash) + .supports_payment_notifications(); + if let Some(absolute_expiry) = absolute_expiry { + builder = builder.absolute_expiry(absolute_expiry); + } + for path in self + .create_blinded_paths(peers, context) + .map_err(|_| Bolt12SemanticError::MissingPaths)? + { + builder = builder.path(path); + } + + builder.build() + } + + /// Constructs a per-order [`Offer`] from a merchant-provided point-of-sale template, for + /// presenting to a customer as a QR code or over another transport. + /// + /// A fresh per-order secret `order_id` is generated and returned alongside the offer. It is + /// sealed both into the offer's `payment_token`, which only the merchant can read, and into + /// the message context of a `notification_path` back to this node with `merchant_node_id` as + /// the introduction node. When the merchant claims the order's payment, it relays the + /// `order_id` recovered from the token in a payment notification over that path, surfaced via + /// [`Event::PaymentNotificationReceived`] once authenticated against the path context. The + /// node therefore needs no persistent order state; the returned `order_id` only correlates + /// the event with the order. + /// + /// `merchant_node_id` must be an announced node this node can receive onion messages through, + /// typically its always-connected peer that provided the template. + /// + /// Errors if the template does not set the `payment_notifications` feature bit or already has + /// any of the per-order fields set. + /// + /// See [bLIP 56](https://github.com/lightning/blips/pull/56) for more information. + /// + /// [`Event::PaymentNotificationReceived`]: crate::events::Event::PaymentNotificationReceived + pub fn create_pos_order_offer( + &self, template: &Offer, merchant_node_id: PublicKey, amount_msats: u64, + description: Option, order_absolute_expiry: Option, entropy_source: ES, + ) -> Result<(Offer, Vec), Bolt12SemanticError> { + let entropy = &entropy_source; + let order_id = entropy.get_secure_random_bytes()[..MIN_ORDER_ID_LEN].to_vec(); + + let context = MessageContext::PosNotification(PosNotificationContext::InboundOrder { + order_id: order_id.clone(), + amount_msats, + description: description.clone(), + order_absolute_expiry, + }); + let intermediate_nodes = + [MessageForwardNode { node_id: merchant_node_id, short_channel_id: None }]; + // Compact padding keeps the offer scannable as a QR code, trading off how well the + // path's contents are hidden. + let notification_path = BlindedMessagePath::new( + &intermediate_nodes, + self.get_our_node_id(), + self.get_receive_auth_key(), + context, + true, + entropy, + &self.secp_ctx, + ); + + let offer = template + .modify()? + .with_pos_order( + amount_msats, + description, + order_id.clone(), + vec![notification_path], + order_absolute_expiry, + entropy, + &self.secp_ctx, + )? + .build(); + + Ok((offer, order_id)) + } + /// Create an offer for receiving async payments as an often-offline recipient. /// /// Because we may be offline when the payer attempts to request an invoice, you MUST: @@ -982,6 +1208,9 @@ impl OffersMessageFlow { let (payment_hash, payment_secret) = get_payment_info(amount_msats, relative_expiry)?; + let payment_metadata = + self.embed_pos_order_payment_metadata(invoice_request, payment_metadata); + let context = PaymentContext::Bolt12Offer(Bolt12OfferContext { offer_id: invoice_request.offer_id, invoice_request: invoice_request.fields(), @@ -1014,6 +1243,43 @@ impl OffersMessageFlow { Ok((builder, context)) } + /// If the given [`InvoiceRequest`] mirrors a point-of-sale offer whose `payment_token` is + /// decryptable with the request's derived keys, stores the recovered `order_id`, the offer's + /// `notification_path`s, and the offer's expiry in the payment metadata so they can be + /// retrieved when the payment is claimed and a payment notification needs to be sent. + /// + /// See [bLIP 56](https://github.com/lightning/blips/pull/56) for more information. + fn embed_pos_order_payment_metadata( + &self, invoice_request: &VerifiedInvoiceRequest, + mut payment_metadata: Option>>, + ) -> Option>> { + let notification_paths = invoice_request.notification_paths(); + if notification_paths.is_empty() { + return payment_metadata; + } + let token = match invoice_request.payment_token() { + Some(token) => token, + None => return payment_metadata, + }; + let payload = + match PaymentTokenPayload::decrypt(&invoice_request.keys.0.secret_key(), token) { + Ok(payload) => payload, + Err(()) => return payment_metadata, + }; + + let metadata = payment_metadata.get_or_insert_with(BTreeMap::new); + metadata.insert(POS_ORDER_ID_PAYMENT_METADATA_KEY, payload.order_id); + metadata.insert( + POS_NOTIFICATION_PATHS_PAYMENT_METADATA_KEY, + WithoutLength(¬ification_paths.to_vec()).encode(), + ); + if let Some(expiry) = invoice_request.absolute_expiry() { + metadata.insert(POS_ORDER_EXPIRY_PAYMENT_METADATA_KEY, expiry.as_secs().encode()); + } + + payment_metadata + } + /// Creates an [`InvoiceBuilder`] for the /// provided [`VerifiedInvoiceRequest`]. /// @@ -1311,6 +1577,170 @@ impl OffersMessageFlow { core::mem::take(&mut self.pending_async_payments_messages.lock().unwrap()) } + /// Gets the enqueued [`PosNotificationMessage`]s with their corresponding + /// [`MessageSendInstructions`]. + pub fn release_pending_pos_notification_messages( + &self, + ) -> Vec<(PosNotificationMessage, MessageSendInstructions)> { + core::mem::take(&mut self.pending_pos_notification_messages.lock().unwrap()) + } + + /// Restores [`PendingPosNotification`]s that were unacknowledged when this node last shut + /// down. Sending resumes on the next timer tick. + pub fn with_pending_pos_notifications( + self, pending_pos_notifications: Vec, + ) -> Self { + *self.pending_pos_notifications.lock().unwrap() = pending_pos_notifications; + self + } + + /// Returns the [`PendingPosNotification`]s that have not been acknowledged yet, for + /// persistence across restarts. + pub fn pending_pos_notifications(&self) -> Vec { + self.pending_pos_notifications.lock().unwrap().clone() + } + + /// Queues a payment notification for the point-of-sale device that constructed the offer the + /// claimed payment was for, using the order data recovered from the payment's metadata. + /// + /// The notification is retried on each timer tick until acknowledged or until the order's + /// expiry plus a grace period has passed. + pub(crate) fn enqueue_pos_payment_notification( + &self, order_id: Vec, notification_paths: Vec, amount_msats: u64, + payment_hash: PaymentHash, payment_preimage: PaymentPreimage, + order_absolute_expiry: Option, + ) { + let retry_until = order_absolute_expiry + .unwrap_or_else(|| { + self.duration_since_epoch() + .saturating_add(DEFAULT_PENDING_POS_NOTIFICATION_LIFETIME) + }) + .saturating_add(POS_NOTIFICATION_GRACE_PERIOD); + let pending = PendingPosNotification { + payment_hash, + payment_preimage, + amount_msats, + order_id, + notification_paths, + retry_until, + }; + + self.enqueue_pending_pos_notification_messages(&pending); + self.pending_pos_notifications.lock().unwrap().push(pending); + } + + /// Re-enqueues sends for all unacknowledged payment notifications, dropping those whose + /// retry window has passed. Intended to be called on each timer tick. + pub(crate) fn requeue_pending_pos_notifications(&self) { + let now = self.duration_since_epoch(); + let mut pending_notifications = self.pending_pos_notifications.lock().unwrap(); + pending_notifications.retain(|pending| { + if pending.retry_until < now { + log_error!( + self.logger, + "Abandoning undeliverable PoS payment notification for payment {}; the \ + point-of-sale device never confirmed the order and manual reconciliation may \ + be required", + pending.payment_hash + ); + false + } else { + true + } + }); + for pending in pending_notifications.iter() { + self.enqueue_pending_pos_notification_messages(pending); + } + } + + /// Recovers the point-of-sale order data embedded in a claimed payment's metadata by + /// [`Self::create_invoice_builder_from_invoice_request_with_keys`], returning the `order_id`, + /// the offer's `notification_path`s, and the order's expiry. + /// + /// Returns `None` if the payment was not for a point-of-sale offer. + pub(crate) fn pos_order_data_from_payment_metadata( + payment_metadata: &BTreeMap>, + ) -> Option<(Vec, Vec, Option)> { + let order_id = payment_metadata.get(&POS_ORDER_ID_PAYMENT_METADATA_KEY)?; + let paths_bytes = payment_metadata.get(&POS_NOTIFICATION_PATHS_PAYMENT_METADATA_KEY)?; + + let mut cursor = crate::io::Cursor::new(paths_bytes); + let mut reader = FixedLengthReader::new(&mut cursor, paths_bytes.len() as u64); + let notification_paths: WithoutLength> = + LengthReadable::read_from_fixed_length_buffer(&mut reader).ok()?; + if notification_paths.0.is_empty() { + return None; + } + + let order_absolute_expiry = payment_metadata + .get(&POS_ORDER_EXPIRY_PAYMENT_METADATA_KEY) + .and_then(|bytes| { + let mut cursor = crate::io::Cursor::new(bytes); + ::read(&mut cursor).ok() + }) + .map(Duration::from_secs); + + Some((order_id.clone(), notification_paths.0, order_absolute_expiry)) + } + + /// Resolves a pending payment notification once the point-of-sale device acknowledged it. + pub(crate) fn resolve_pending_pos_notification(&self, payment_hash: &PaymentHash) { + self.pending_pos_notifications + .lock() + .unwrap() + .retain(|pending| pending.payment_hash != *payment_hash); + } + + fn enqueue_pending_pos_notification_messages(&self, pending: &PendingPosNotification) { + let message = PosNotificationMessage::PaymentNotification(PaymentNotification { + payment_hash: pending.payment_hash, + payment_preimage: pending.payment_preimage, + amount_msats: pending.amount_msats, + order_id: pending.order_id.clone(), + }); + let context = + MessageContext::PosNotification(PosNotificationContext::OutboundNotification { + payment_hash: pending.payment_hash, + }); + let mut queue = self.pending_pos_notification_messages.lock().unwrap(); + for path in pending.notification_paths.iter() { + let instructions = MessageSendInstructions::WithReplyPath { + destination: Destination::BlindedPath(path.clone()), + context: context.clone(), + }; + queue.push((message.clone(), instructions)); + } + } + + /// Enqueues a `payment_proof` onion message to be sent to the point-of-sale device over the + /// given offer's `notification_path`s, proving to the device that this node paid the offer. + /// + /// `proof` is a serialized BOLT 12 payer proof, as defined in + /// [bolts#1295](https://github.com/lightning/bolts/pull/1295). + /// + /// Errors if the offer does not contain any `notification_path`s. + /// + /// See [bLIP 56](https://github.com/lightning/blips/pull/56) for more information. + pub fn enqueue_payment_proof( + &self, offer: &Offer, proof: Vec, + ) -> Result<(), Bolt12SemanticError> { + let notification_paths = offer.notification_paths(); + if notification_paths.is_empty() { + return Err(Bolt12SemanticError::MissingPaths); + } + + let message = PosNotificationMessage::PaymentProof(PaymentProof { proof }); + let mut queue = self.pending_pos_notification_messages.lock().unwrap(); + for path in notification_paths.iter() { + let instructions = MessageSendInstructions::WithoutReplyPath { + destination: Destination::BlindedPath(path.clone()), + }; + queue.push((message.clone(), instructions)); + } + + Ok(()) + } + /// Retrieve an [`Offer`] for receiving async payments as an often-offline recipient. Will only /// return an offer if [`Self::set_paths_to_static_invoice_server`] was called and we succeeded in /// interactively building a [`StaticInvoice`] with the static invoice server. From ee497460552a6c72c0ad41bac09f0704a2006170 Mon Sep 17 00:00:00 2001 From: Vincenzo Palazzo Date: Wed, 10 Jun 2026 19:00:03 +0200 Subject: [PATCH 6/6] ln: Add bLIP 56 functional tests Covers the full customer/merchant/PoS flow: template creation, per-order offer construction, payment by an unmodified wallet, notification delivery, authentication, acknowledgement, retry, and duplicate handling. Also covers rejection of forged notifications, each nack reason code, expired orders, and invoice requests for bare templates or offers whose cleartext fields do not match the sealed payment token, as well as payment proof delivery. Co-Authored-By: Claude Fable 5 --- lightning/src/ln/mod.rs | 3 + lightning/src/ln/pos_notification_tests.rs | 554 +++++++++++++++++++++ 2 files changed, 557 insertions(+) create mode 100644 lightning/src/ln/pos_notification_tests.rs diff --git a/lightning/src/ln/mod.rs b/lightning/src/ln/mod.rs index d6e0b92f1d0..1d5297dd213 100644 --- a/lightning/src/ln/mod.rs +++ b/lightning/src/ln/mod.rs @@ -104,6 +104,9 @@ mod onion_route_tests; mod payment_tests; #[cfg(test)] #[allow(unused_mut)] +mod pos_notification_tests; +#[cfg(test)] +#[allow(unused_mut)] mod priv_short_conf_tests; #[cfg(test)] mod quiescence_tests; diff --git a/lightning/src/ln/pos_notification_tests.rs b/lightning/src/ln/pos_notification_tests.rs new file mode 100644 index 00000000000..e249bc234f3 --- /dev/null +++ b/lightning/src/ln/pos_notification_tests.rs @@ -0,0 +1,554 @@ +// This file is Copyright its original authors, visible in version control +// history. +// +// This file is licensed under the Apache License, Version 2.0 or the MIT license +// , at your option. +// You may not use this file except in accordance with one or both of these +// licenses. + +//! Functional tests for point-of-sale payment notifications, per +//! [bLIP 56](https://github.com/lightning/blips/pull/56). +//! +//! Tests use three nodes: +//! +//! Customer --- Merchant ... PoS +//! +//! where the customer and merchant have an announced channel and the point-of-sale device has no +//! channels, communicating with the merchant via onion messages only. + +use alloc::collections::BTreeMap; + +use bitcoin::secp256k1::Secp256k1; +use core::time::Duration; + +use crate::blinded_path::message::{MessageContext, PosNotificationContext}; +use crate::blinded_path::payment::{Bolt12OfferContext, DummyTlvs, PaymentContext}; +use crate::events::{Event, PaidBolt12Invoice, PaymentPurpose}; +use crate::ln::channelmanager::PaymentId; +use crate::ln::functional_test_utils::*; +use crate::ln::msgs::{BaseMessageHandler, OnionMessage, OnionMessageHandler}; +use crate::ln::offers_tests::extract_invoice_request; +use crate::offers::flow::{ + POS_NOTIFICATION_PATHS_PAYMENT_METADATA_KEY, POS_ORDER_ID_PAYMENT_METADATA_KEY, +}; +use crate::offers::invoice::Bolt12Invoice; +use crate::offers::invoice_request::InvoiceRequestFields; +use crate::offers::offer::{Amount, Offer}; +use crate::onion_message::messenger::{Destination, MessageSendInstructions, PeeledOnion}; +use crate::onion_message::offers::OffersMessage; +use crate::onion_message::pos_notification::{ + NackReason, PaymentNotification, PosNotificationMessage, +}; +use crate::routing::router::DEFAULT_PAYMENT_DUMMY_HOPS; +use crate::types::payment::{PaymentHash, PaymentPreimage}; +use crate::util::ser::{WithoutLength, Writeable}; + +use crate::prelude::*; + +const ORDER_AMOUNT_MSATS: u64 = 10_000_000; + +fn expected_pos_payment_context( + offer: &Offer, order_id: &[u8], invoice_request_fields: InvoiceRequestFields, +) -> PaymentContext { + let mut payment_metadata = BTreeMap::new(); + payment_metadata.insert(POS_ORDER_ID_PAYMENT_METADATA_KEY, order_id.to_vec()); + payment_metadata.insert( + POS_NOTIFICATION_PATHS_PAYMENT_METADATA_KEY, + WithoutLength(&offer.notification_paths().to_vec()).encode(), + ); + + PaymentContext::Bolt12Offer(Bolt12OfferContext { + offer_id: offer.id(), + invoice_request: invoice_request_fields, + payment_metadata: Some(payment_metadata), + }) +} + +/// Drives the customer's `invoice_request` to the merchant and returns the resulting invoice +/// after delivering it back to the customer. +fn deliver_invoice_request_and_get_invoice<'a, 'b, 'c>( + customer: &Node<'a, 'b, 'c>, merchant: &Node<'a, 'b, 'c>, +) -> Option { + let customer_id = customer.node.get_our_node_id(); + let merchant_id = merchant.node.get_our_node_id(); + + let onion_message = customer.onion_messenger.next_onion_message_for_peer(merchant_id).unwrap(); + merchant.onion_messenger.handle_onion_message(customer_id, &onion_message); + + let onion_message = merchant.onion_messenger.next_onion_message_for_peer(customer_id)?; + customer.onion_messenger.handle_onion_message(merchant_id, &onion_message); + + match customer.onion_messenger.peel_onion_message(&onion_message) { + Ok(PeeledOnion::Offers(OffersMessage::Invoice(invoice), _, _)) => Some(invoice), + Ok(_) => None, + Err(e) => panic!("Failed to process onion message {:?}", e), + } +} + +/// Delivers the merchant's queued payment notification to the point-of-sale device, returning the +/// device's response, if any. +fn deliver_pos_notification<'a, 'b, 'c>( + merchant: &Node<'a, 'b, 'c>, pos: &Node<'a, 'b, 'c>, +) -> Option { + let merchant_id = merchant.node.get_our_node_id(); + let pos_id = pos.node.get_our_node_id(); + + let onion_message = merchant.onion_messenger.next_onion_message_for_peer(pos_id).unwrap(); + pos.onion_messenger.handle_onion_message(merchant_id, &onion_message); + + pos.onion_messenger.next_onion_message_for_peer(merchant_id) +} + +fn extract_nack_reason<'a, 'b, 'c>( + merchant: &Node<'a, 'b, 'c>, message: &OnionMessage, +) -> NackReason { + match merchant.onion_messenger.peel_onion_message(message) { + Ok(PeeledOnion::PosNotification(PosNotificationMessage::NotificationNack(nack), _, _)) => { + nack.reason + }, + Ok(_) => panic!("Unexpected onion message"), + Err(e) => panic!("Failed to process onion message {:?}", e), + } +} + +/// Creates the customer/merchant/PoS network, returning the per-order offer and its `order_id`. +fn create_pos_order<'a, 'b, 'c>( + merchant: &Node<'a, 'b, 'c>, pos: &Node<'a, 'b, 'c>, order_absolute_expiry: Option, +) -> (Offer, Vec) { + let merchant_id = merchant.node.get_our_node_id(); + + let template = merchant.node.create_pos_delegation_template(None).unwrap(); + assert!(template.offer_features().supports_payment_notifications()); + assert!(template.amount().is_none()); + assert!(template.description().is_none()); + assert!(template.notification_paths().is_empty()); + assert!(template.payment_token().is_none()); + assert!(!template.paths().is_empty()); + + let (offer, order_id) = pos + .node + .create_pos_order_offer( + &template, + merchant_id, + ORDER_AMOUNT_MSATS, + Some("coffee".to_string()), + order_absolute_expiry, + ) + .unwrap(); + assert!(offer.offer_features().supports_payment_notifications()); + assert_eq!(offer.amount(), Some(Amount::Bitcoin { amount_msats: ORDER_AMOUNT_MSATS })); + assert_eq!(offer.description().unwrap().to_string(), "coffee"); + assert_eq!(offer.notification_paths().len(), 1); + assert!(offer.payment_token().is_some()); + + // A standard wallet must be able to parse the per-order offer, with the new odd TLV records + // reflected in re-parsed bytes. + let reparsed = Offer::try_from(offer.as_ref().to_vec()).unwrap(); + assert_eq!(reparsed, offer); + + (offer, order_id) +} + +fn pay_pos_order<'a, 'b, 'c>( + customer: &Node<'a, 'b, 'c>, merchant: &Node<'a, 'b, 'c>, offer: &Offer, order_id: &[u8], +) { + let payment_id = PaymentId([1; 32]); + customer.node.pay_for_offer(offer, None, payment_id, Default::default()).unwrap(); + + let onion_message = + customer.onion_messenger.next_onion_message_for_peer(merchant.node.get_our_node_id()); + let onion_message = onion_message.unwrap(); + let (invoice_request, _) = extract_invoice_request(merchant, &onion_message); + merchant.onion_messenger.handle_onion_message(customer.node.get_our_node_id(), &onion_message); + + let onion_message = merchant + .onion_messenger + .next_onion_message_for_peer(customer.node.get_our_node_id()) + .unwrap(); + customer.onion_messenger.handle_onion_message(merchant.node.get_our_node_id(), &onion_message); + + let invoice = match customer.onion_messenger.peel_onion_message(&onion_message) { + Ok(PeeledOnion::Offers(OffersMessage::Invoice(invoice), _, _)) => invoice, + Ok(_) => panic!("Unexpected onion message"), + Err(e) => panic!("Failed to process onion message {:?}", e), + }; + assert_eq!(invoice.amount_msats(), ORDER_AMOUNT_MSATS); + + route_bolt12_payment(customer, &[merchant], &invoice); + + let expected_payment_context = expected_pos_payment_context( + offer, + order_id, + InvoiceRequestFields { + payer_signing_pubkey: invoice_request.payer_signing_pubkey(), + quantity: None, + payer_note_truncated: None, + human_readable_name: None, + }, + ); + claim_bolt12_payment(customer, &[merchant], expected_payment_context, &invoice); +} + +fn route_bolt12_payment<'a, 'b, 'c>( + node: &Node<'a, 'b, 'c>, path: &[&Node<'a, 'b, 'c>], invoice: &Bolt12Invoice, +) { + check_added_monitors(node, 1); + + let mut events = node.node.get_and_clear_pending_msg_events(); + assert_eq!(events.len(), 1); + let ev = remove_first_msg_event_to_node(&path[0].node.get_our_node_id(), &mut events); + + let amount_msats = invoice.amount_msats(); + let payment_hash = invoice.payment_hash(); + let args = PassAlongPathArgs::new(node, path, amount_msats, payment_hash, ev) + .without_clearing_recipient_events() + .with_dummy_tlvs(&[DummyTlvs::default(); DEFAULT_PAYMENT_DUMMY_HOPS]); + do_pass_along_path(args); +} + +fn claim_bolt12_payment<'a, 'b, 'c>( + node: &Node<'a, 'b, 'c>, path: &[&Node<'a, 'b, 'c>], expected_payment_context: PaymentContext, + invoice: &Bolt12Invoice, +) { + let recipient = path.last().expect("Empty path?"); + let payment_purpose = match get_event!(recipient, Event::PaymentClaimable) { + Event::PaymentClaimable { purpose, .. } => purpose, + _ => panic!("No Event::PaymentClaimable"), + }; + let payment_preimage = match payment_purpose.preimage() { + Some(preimage) => preimage, + None => panic!("No preimage in Event::PaymentClaimable"), + }; + let context = match payment_purpose { + PaymentPurpose::Bolt12OfferPayment { payment_context, .. } => { + PaymentContext::Bolt12Offer(payment_context) + }, + _ => panic!("Unexpected payment purpose: {:?}", payment_purpose), + }; + assert_eq!(context, expected_payment_context); + + let expected_paths = [path]; + let args = ClaimAlongRouteArgs::new(node, &expected_paths, payment_preimage); + let (inv, _) = claim_payment_along_route(args); + assert_eq!(inv, Some(PaidBolt12Invoice::Bolt12Invoice(invoice.clone()))); +} + +fn expect_payment_notification_received<'a, 'b, 'c>( + pos: &Node<'a, 'b, 'c>, expected_order_id: &[u8], +) { + let events = pos.node.get_and_clear_pending_events(); + assert_eq!(events.len(), 1); + match &events[0] { + Event::PaymentNotificationReceived { + order_id, + amount_msats, + payment_hash, + payment_preimage, + description, + } => { + assert_eq!(order_id, &expected_order_id.to_vec()); + assert_eq!(*amount_msats, ORDER_AMOUNT_MSATS); + assert_eq!(PaymentHash::from(*payment_preimage), *payment_hash); + assert_eq!(description.as_deref(), Some("coffee")); + }, + event => panic!("Unexpected event: {:?}", event), + } +} + +/// Checks the full bLIP 56 flow: the merchant creates a template, the point-of-sale device +/// derives a per-order offer, the customer pays it without any sender-side modifications, and the +/// merchant's payment notification is authenticated and acknowledged by the device. +#[test] +fn pays_pos_order_offer_and_delivers_notification() { + let chanmon_cfgs = create_chanmon_cfgs(3); + let node_cfgs = create_node_cfgs(3, &chanmon_cfgs); + let node_chanmgrs = create_node_chanmgrs(3, &node_cfgs, &[None, None, None]); + let nodes = create_network(3, &node_cfgs, &node_chanmgrs); + + create_announced_chan_between_nodes_with_value(&nodes, 0, 1, 10_000_000, 1_000_000_000); + + let (customer, merchant, pos) = (&nodes[0], &nodes[1], &nodes[2]); + let pos_id = pos.node.get_our_node_id(); + + // The point-of-sale device only ever talks to the merchant. + disconnect_peers(customer, &[pos]); + + let (offer, order_id) = create_pos_order(merchant, pos, None); + pay_pos_order(customer, merchant, &offer, &order_id); + + // Claiming the payment queues a notification to the point-of-sale device. + let ack = deliver_pos_notification(merchant, pos).unwrap(); + expect_payment_notification_received(pos, &order_id); + + // The merchant retries the notification on timer ticks until it is acknowledged. A duplicate + // is authenticated and acknowledged again; deduplication is the event consumer's + // responsibility. + merchant.node.timer_tick_occurred(); + let duplicate_ack = deliver_pos_notification(merchant, pos).unwrap(); + expect_payment_notification_received(pos, &order_id); + + merchant.onion_messenger.handle_onion_message(pos_id, &ack); + merchant.onion_messenger.handle_onion_message(pos_id, &duplicate_ack); + + // Once acknowledged, the notification is no longer retried. + merchant.node.timer_tick_occurred(); + assert!(merchant.onion_messenger.next_onion_message_for_peer(pos_id).is_none()); +} + +/// Checks that a customer cannot forge a payment notification: it knows the notification path +/// from the offer but not the `order_id` sealed in the `payment_token`, so its notification is +/// rejected without generating an event. +#[test] +fn rejects_forged_payment_notification() { + let chanmon_cfgs = create_chanmon_cfgs(3); + let node_cfgs = create_node_cfgs(3, &chanmon_cfgs); + let node_chanmgrs = create_node_chanmgrs(3, &node_cfgs, &[None, None, None]); + let nodes = create_network(3, &node_cfgs, &node_chanmgrs); + + create_announced_chan_between_nodes_with_value(&nodes, 0, 1, 10_000_000, 1_000_000_000); + + let (customer, merchant, pos) = (&nodes[0], &nodes[1], &nodes[2]); + let customer_id = customer.node.get_our_node_id(); + let merchant_id = merchant.node.get_our_node_id(); + let pos_id = pos.node.get_our_node_id(); + + disconnect_peers(customer, &[pos]); + + let (offer, _order_id) = create_pos_order(merchant, pos, None); + + // The customer asserts an arbitrary amount with a self-consistent preimage but cannot know + // the order_id, since the token ciphertext is opaque to it. + let payment_preimage = PaymentPreimage([7; 32]); + let forged = PosNotificationMessage::PaymentNotification(PaymentNotification { + payment_hash: PaymentHash::from(payment_preimage), + payment_preimage, + amount_msats: ORDER_AMOUNT_MSATS, + order_id: vec![9; 16], + }); + let instructions = MessageSendInstructions::WithoutReplyPath { + destination: Destination::BlindedPath(offer.notification_paths()[0].clone()), + }; + customer.onion_messenger.send_onion_message(forged, instructions).unwrap(); + + // The forged notification reaches the device via the merchant, the path's introduction node. + let onion_message = customer.onion_messenger.next_onion_message_for_peer(merchant_id).unwrap(); + merchant.onion_messenger.handle_onion_message(customer_id, &onion_message); + let onion_message = merchant.onion_messenger.next_onion_message_for_peer(pos_id).unwrap(); + pos.onion_messenger.handle_onion_message(merchant_id, &onion_message); + + assert!(pos.node.get_and_clear_pending_events().is_empty()); +} + +/// Checks each rejection reason for a payment notification that fails validation against the +/// notification path's message context. +#[test] +fn nacks_invalid_payment_notifications() { + let chanmon_cfgs = create_chanmon_cfgs(3); + let node_cfgs = create_node_cfgs(3, &chanmon_cfgs); + let node_chanmgrs = create_node_chanmgrs(3, &node_cfgs, &[None, None, None]); + let nodes = create_network(3, &node_cfgs, &node_chanmgrs); + + create_announced_chan_between_nodes_with_value(&nodes, 0, 1, 10_000_000, 1_000_000_000); + + let (customer, merchant, pos) = (&nodes[0], &nodes[1], &nodes[2]); + disconnect_peers(customer, &[pos]); + + let (offer, order_id) = create_pos_order(merchant, pos, None); + let payment_preimage = PaymentPreimage([7; 32]); + let payment_hash = PaymentHash::from(payment_preimage); + + let cases = [ + ( + PaymentNotification { + payment_hash, + payment_preimage, + amount_msats: ORDER_AMOUNT_MSATS, + order_id: vec![9; 16], + }, + NackReason::OrderIdMismatch, + ), + ( + PaymentNotification { + payment_hash, + payment_preimage, + amount_msats: ORDER_AMOUNT_MSATS - 1, + order_id: order_id.clone(), + }, + NackReason::AmountInsufficient, + ), + ( + PaymentNotification { + payment_hash: PaymentHash([8; 32]), + payment_preimage, + amount_msats: ORDER_AMOUNT_MSATS, + order_id: order_id.clone(), + }, + NackReason::InvalidPreimage, + ), + ]; + + for (notification, expected_reason) in cases { + send_notification_from_merchant(merchant, &offer, notification, payment_hash); + let nack = deliver_pos_notification(merchant, pos).unwrap(); + assert_eq!(extract_nack_reason(merchant, &nack), expected_reason); + assert!(pos.node.get_and_clear_pending_events().is_empty()); + } +} + +/// Checks that notifications for an expired order are rejected. +#[test] +fn nacks_payment_notification_for_expired_order() { + let chanmon_cfgs = create_chanmon_cfgs(3); + let node_cfgs = create_node_cfgs(3, &chanmon_cfgs); + let node_chanmgrs = create_node_chanmgrs(3, &node_cfgs, &[None, None, None]); + let nodes = create_network(3, &node_cfgs, &node_chanmgrs); + + create_announced_chan_between_nodes_with_value(&nodes, 0, 1, 10_000_000, 1_000_000_000); + + let (customer, merchant, pos) = (&nodes[0], &nodes[1], &nodes[2]); + disconnect_peers(customer, &[pos]); + + let expired = Some(pos.node.duration_since_epoch() - Duration::from_secs(1)); + let (offer, order_id) = create_pos_order(merchant, pos, expired); + + let payment_preimage = PaymentPreimage([7; 32]); + let payment_hash = PaymentHash::from(payment_preimage); + let notification = PaymentNotification { + payment_hash, + payment_preimage, + amount_msats: ORDER_AMOUNT_MSATS, + order_id, + }; + send_notification_from_merchant(merchant, &offer, notification, payment_hash); + let nack = deliver_pos_notification(merchant, pos).unwrap(); + assert_eq!(extract_nack_reason(merchant, &nack), NackReason::OrderExpired); + assert!(pos.node.get_and_clear_pending_events().is_empty()); +} + +fn send_notification_from_merchant<'a, 'b, 'c>( + merchant: &Node<'a, 'b, 'c>, offer: &Offer, notification: PaymentNotification, + payment_hash: PaymentHash, +) { + let message = PosNotificationMessage::PaymentNotification(notification); + let instructions = MessageSendInstructions::WithReplyPath { + destination: Destination::BlindedPath(offer.notification_paths()[0].clone()), + context: MessageContext::PosNotification(PosNotificationContext::OutboundNotification { + payment_hash, + }), + }; + merchant.onion_messenger.send_onion_message(message, instructions).unwrap(); +} + +/// Checks that the merchant rejects invoice requests for the bare template and for per-order +/// offers whose cleartext fields do not match the fields sealed in the `payment_token`. +#[test] +fn rejects_invoice_request_for_template_or_tampered_offer() { + let chanmon_cfgs = create_chanmon_cfgs(3); + let node_cfgs = create_node_cfgs(3, &chanmon_cfgs); + let node_chanmgrs = create_node_chanmgrs(3, &node_cfgs, &[None, None, None]); + let nodes = create_network(3, &node_cfgs, &node_chanmgrs); + + create_announced_chan_between_nodes_with_value(&nodes, 0, 1, 10_000_000, 1_000_000_000); + + let (customer, merchant, pos) = (&nodes[0], &nodes[1], &nodes[2]); + let merchant_id = merchant.node.get_our_node_id(); + disconnect_peers(customer, &[pos]); + + // Paying the bare template is rejected: it advertises payment_notifications but carries no + // notification_path or payment_token, so no notification could be delivered. + let template = merchant.node.create_pos_delegation_template(None).unwrap(); + let payment_id = PaymentId([2; 32]); + customer + .node + .pay_for_offer(&template, Some(ORDER_AMOUNT_MSATS), payment_id, Default::default()) + .unwrap(); + assert!(deliver_invoice_request_and_get_invoice(customer, merchant).is_none()); + + // A per-order offer whose amount was lowered relative to the amount sealed in the token is + // rejected before any payment happens. + let (full_price_offer, _) = pos + .node + .create_pos_order_offer( + &template, + merchant_id, + ORDER_AMOUNT_MSATS, + Some("coffee".to_string()), + None, + ) + .unwrap(); + let tampered_offer = template + .modify() + .unwrap() + .with_pos_order( + ORDER_AMOUNT_MSATS / 2, + Some("coffee".to_string()), + vec![3; 16], + full_price_offer.notification_paths().to_vec(), + None, + customer.keys_manager, + &Secp256k1::new(), + ) + .unwrap() + .payment_token_unchecked(full_price_offer.payment_token().unwrap().clone()) + .build(); + + let payment_id = PaymentId([3; 32]); + customer.node.pay_for_offer(&tampered_offer, None, payment_id, Default::default()).unwrap(); + assert!(deliver_invoice_request_and_get_invoice(customer, merchant).is_none()); +} + +/// Checks that a customer's `payment_proof` is delivered to the point-of-sale device over the +/// offer's notification path and surfaced unverified. +#[test] +fn delivers_payment_proof_to_pos() { + let chanmon_cfgs = create_chanmon_cfgs(3); + let node_cfgs = create_node_cfgs(3, &chanmon_cfgs); + let node_chanmgrs = create_node_chanmgrs(3, &node_cfgs, &[None, None, None]); + let nodes = create_network(3, &node_cfgs, &node_chanmgrs); + + create_announced_chan_between_nodes_with_value(&nodes, 0, 1, 10_000_000, 1_000_000_000); + + let (customer, merchant, pos) = (&nodes[0], &nodes[1], &nodes[2]); + let customer_id = customer.node.get_our_node_id(); + let merchant_id = merchant.node.get_our_node_id(); + let pos_id = pos.node.get_our_node_id(); + + disconnect_peers(customer, &[pos]); + + let (offer, order_id) = create_pos_order(merchant, pos, None); + + let proof = vec![42; 80]; + customer.node.send_payment_proof(&offer, proof.clone()).unwrap(); + + let onion_message = customer.onion_messenger.next_onion_message_for_peer(merchant_id).unwrap(); + merchant.onion_messenger.handle_onion_message(customer_id, &onion_message); + let onion_message = merchant.onion_messenger.next_onion_message_for_peer(pos_id).unwrap(); + pos.onion_messenger.handle_onion_message(merchant_id, &onion_message); + + let events = pos.node.get_and_clear_pending_events(); + assert_eq!(events.len(), 1); + match &events[0] { + Event::PaymentProofReceived { + order_id: event_order_id, + proof: event_proof, + amount_msats, + description, + } => { + assert_eq!(event_order_id, &order_id); + assert_eq!(event_proof, &proof); + assert_eq!(*amount_msats, ORDER_AMOUNT_MSATS); + assert_eq!(description.as_deref(), Some("coffee")); + }, + event => panic!("Unexpected event: {:?}", event), + } +} + +fn disconnect_peers<'a, 'b, 'c>(node_a: &Node<'a, 'b, 'c>, peers: &[&Node<'a, 'b, 'c>]) { + for node_b in peers { + node_a.node.peer_disconnected(node_b.node.get_our_node_id()); + node_b.node.peer_disconnected(node_a.node.get_our_node_id()); + node_a.onion_messenger.peer_disconnected(node_b.node.get_our_node_id()); + node_b.onion_messenger.peer_disconnected(node_a.node.get_our_node_id()); + } +}