ln: persist the paid BOLT 12 invoice and build payer proofs
What changed, and why it matters
This commit is a feature addition, not a vulnerability fix. It extends rust-lightning's BOLT 12 payment support so that when a wallet pays a BOLT 12 invoice, the paid invoice is saved through retries and restarts and is later exposed in the PaymentSent event as a PaidBolt12Invoice. The wallet can then use that object to build a cryptographic 'payer proof' that selectively discloses invoice fields to prove to a third party that it paid. The payer signing key is re-derived from data already in the invoice, so no extra secret key storage is needed. There is no indication in the commit that this fixes a security bug; it is new functionality with tests.
Treat as a normal feature commit. Review the new PayerProof construction and serialization paths for correctness, ensure the re-derived payer signing key cannot be misused to sign arbitrary data, and verify that persisted PaidBolt12Invoice data is handled consistently across upgrades. No immediate security response is warranted based on the supplied materials.
Security signals we found
New BOLT 12 payer proof feature: persists paid invoice across retries/restarts and exposes it in Event::PaymentSent
Payer signing key re-derived from invoice payer metadata rather than storing extra key material
Adds end-to-end test for proof creation, verification, and bech32 round-trip
Adds serialization round-trip test for Retryable payment carrying bolt12_invoice
No security bug fix language, CVE references, or vulnerability disclosure present in commit or diff
Evidence from the diff
The change moves PaidBolt12Invoice from events::mod into a new offers::payer_proof module, updates Event::PaymentSent to carry the new PaidBolt12Invoice type, and persists the bolt12_invoice field inside PendingOutboundPayment::Retryable so it survives serialization/restoration. It adds an end-to-end test that pays a BOLT 12 offer and then calls PaidBolt12Invoice::prove_payer_derived with the payment preimage, expanded key, payment_id, and secp context to build and sign a PayerProof, followed by bech32 encoding/parsing and field verification. A unit test also round-trips a Retryable payment to ensure the invoice is preserved. No security-relevant bug fixes, bounds checks, or cryptographic corrections are visible in the diff.
Changed components
lightning/src/events/mod.rslightning/src/ln/outbound_payment.rslightning/src/ln/functional_test_utils.rslightning/src/ln/offers_tests.rsoffers::payer_proof module (new home of PaidBolt12Invoice)Inspect captured patch +226 / −28
diff --git a/lightning/src/events/mod.rs b/lightning/src/events/mod.rs
index 2e56d35..1f1a358 100644
--- a/lightning/src/events/mod.rs
+++ b/lightning/src/events/mod.rs
@@ -32,6 +32,7 @@ use crate::ln::outbound_payment::RecipientOnionFields;
use crate::ln::types::ChannelId;
use crate::offers::invoice::Bolt12Invoice;
use crate::offers::invoice_request::InvoiceRequest;
+pub use crate::offers::payer_proof::PaidBolt12Invoice;
use crate::offers::static_invoice::StaticInvoice;
use crate::onion_message::messenger::Responder;
use crate::routing::gossip::NetworkUpdate;
@@ -1206,17 +1207,13 @@ pub enum Event {
///
/// [`Route::get_total_fees`]: crate::routing::router::Route::get_total_fees
fee_paid_msat: Option<u64>,
- /// The BOLT 12 invoice that was paid. `None` if the payment was a non BOLT 12 payment.
+ /// The paid BOLT 12 invoice bundled with the data needed to construct a
+ /// [`PayerProof`], which selectively discloses invoice fields to prove payment to a
+ /// third party.
///
- /// The BOLT 12 invoice is useful for proof of payment because it contains the
- /// payment hash. A third party can verify that the payment was made by
- /// showing the invoice and confirming that the payment hash matches
- /// the hash of the payment preimage.
+ /// `None` for non-BOLT 12 payments.
///
- /// However, the [`PaidBolt12Invoice`] can also be of type [`StaticInvoice`], which
- /// is a special [`Bolt12Invoice`] where proof of payment is not possible.
- ///
- /// [`StaticInvoice`]: crate::offers::static_invoice::StaticInvoice
+ /// [`PayerProof`]: crate::offers::payer_proof::PayerProof
bolt12_invoice: Option<PaidBolt12Invoice>,
},
/// Indicates an outbound payment failed. Individual [`Event::PaymentPathFailed`] events
@@ -3314,19 +3311,3 @@ impl<T: EventHandler> EventHandler for Arc<T> {
self.deref().handle_event(event)
}
}
-
-/// The BOLT 12 invoice that was paid, surfaced in [`Event::PaymentSent::bolt12_invoice`].
-#[derive(Clone, Debug, PartialEq, Eq, Hash)]
-pub enum PaidBolt12Invoice {
- /// The BOLT 12 invoice specified by the BOLT 12 specification,
- /// allowing the user to perform proof of payment.
- Bolt12Invoice(Bolt12Invoice),
- /// The Static invoice, used in the async payment specification update proposal,
- /// where the user cannot perform proof of payment.
- StaticInvoice(StaticInvoice),
-}
-
-impl_ser_tlv_based_enum!(PaidBolt12Invoice,
- {0, Bolt12Invoice} => (),
- {2, StaticInvoice} => (),
-);
diff --git a/lightning/src/ln/functional_test_utils.rs b/lightning/src/ln/functional_test_utils.rs
index 6e855c2..5fc3322 100644
--- a/lightning/src/ln/functional_test_utils.rs
+++ b/lightning/src/ln/functional_test_utils.rs
@@ -20,7 +20,7 @@ use crate::events::bump_transaction::sync::BumpTransactionEventHandlerSync;
use crate::events::bump_transaction::BumpTransactionEvent;
use crate::events::{
ClaimedHTLC, ClosureReason, Event, FundingInfo, HTLCHandlingFailureType,
- NegotiationFailureReason, PaidBolt12Invoice, PathFailure, PaymentFailureReason, PaymentPurpose,
+ NegotiationFailureReason, PathFailure, PaymentFailureReason, PaymentPurpose,
};
use crate::ln::chan_utils::{
commitment_tx_base_weight, COMMITMENT_TX_WEIGHT_PER_HTLC, TRUC_MAX_WEIGHT,
@@ -39,6 +39,7 @@ use crate::ln::outbound_payment::RecipientOnionFields;
use crate::ln::outbound_payment::Retry;
use crate::ln::peer_handler::IgnoringMessageHandler;
use crate::ln::types::ChannelId;
+use crate::offers::payer_proof::PaidBolt12Invoice;
use crate::onion_message::messenger::OnionMessenger;
use crate::routing::gossip::{NetworkGraph, NetworkUpdate, P2PGossipSync};
use crate::routing::router::{self, PaymentParameters, Route, RouteParameters};
@@ -3016,6 +3017,7 @@ pub fn expect_payment_sent<CM: AChannelManager, H: NodeHolder<CM = CM>>(
ref amount_msat,
ref fee_paid_msat,
ref bolt12_invoice,
+ ..
} => {
assert_eq!(expected_payment_preimage, *payment_preimage);
assert_eq!(expected_payment_hash, *payment_hash);
diff --git a/lightning/src/ln/offers_tests.rs b/lightning/src/ln/offers_tests.rs
index 8f07316..68a89ba 100644
--- a/lightning/src/ln/offers_tests.rs
+++ b/lightning/src/ln/offers_tests.rs
@@ -64,11 +64,12 @@ use crate::offers::invoice_request::{InvoiceRequest, InvoiceRequestFields, Invoi
use crate::offers::nonce::Nonce;
use crate::offers::offer::OfferBuilder;
use crate::offers::parse::Bolt12SemanticError;
+use crate::offers::payer_proof::PayerProof;
use crate::onion_message::messenger::{DefaultMessageRouter, Destination, MessageRouter, MessageSendInstructions, NodeIdMessageRouter, NullMessageRouter, PeeledOnion, DUMMY_HOPS_PATH_LENGTH, QR_CODED_DUMMY_HOPS_PATH_LENGTH};
use crate::onion_message::offers::OffersMessage;
use crate::routing::router::{DEFAULT_PAYMENT_DUMMY_HOPS, PaymentParameters, RouteParameters, RouteParametersConfig};
use crate::sign::NodeSigner;
-use crate::util::ser::Writeable;
+use crate::util::ser::{MaybeReadable, Writeable};
/// This used to determine whether we built a compact path or not, but now its just a random
/// constant we apply to blinded path expiry in these tests.
@@ -234,6 +235,22 @@ fn extract_offer_nonce<'a, 'b, 'c>(node: &Node<'a, 'b, 'c>, message: &OnionMessa
}
}
+/// Extract the payer's [`PaymentId`] from an invoice onion message received by the payer.
+///
+/// When the payer receives an invoice through their reply path, the blinded path context carries
+/// the [`PaymentId`] for the payment. The payer signing key needed to build a
+/// [`PayerProof`](crate::offers::payer_proof::PayerProof) via
+/// [`PaidBolt12Invoice::prove_payer_derived`] is re-derived from the invoice's own payer metadata.
+fn extract_payer_context<'a, 'b, 'c>(node: &Node<'a, 'b, 'c>, message: &OnionMessage) -> PaymentId {
+ match node.onion_messenger.peel_onion_message(message) {
+ Ok(PeeledOnion::Offers(_, Some(OffersContext::OutboundPaymentForOffer { payment_id, .. }), _)) => payment_id,
+ Ok(PeeledOnion::Offers(_, context, _)) => panic!("Expected OutboundPaymentForOffer context, got: {:?}", context),
+ Ok(PeeledOnion::Forward(_, _)) => panic!("Unexpected onion message forward"),
+ Ok(_) => panic!("Unexpected onion message"),
+ Err(e) => panic!("Failed to process onion message {:?}", e),
+ }
+}
+
pub(super) fn extract_invoice_request<'a, 'b, 'c>(
node: &Node<'a, 'b, 'c>, message: &OnionMessage
) -> (InvoiceRequest, BlindedMessagePath) {
@@ -2676,3 +2693,141 @@ fn creates_and_pays_for_phantom_offer() {
assert!(nodes[0].onion_messenger.next_onion_message_for_peer(node_c_id).is_none());
}
}
+
+/// Tests the full payer proof lifecycle: offer -> invoice_request -> invoice -> payment ->
+/// proof creation with derived key signing -> verification -> bech32 round-trip.
+///
+/// This exercises the primary API path where a wallet pays a BOLT 12 offer and then creates
+/// a payer proof using the derived signing key (same key derivation as the invoice request).
+#[test]
+fn creates_and_verifies_payer_proof_after_offer_payment() {
+ let chanmon_cfgs = create_chanmon_cfgs(2);
+ let node_cfgs = create_node_cfgs(2, &chanmon_cfgs);
+ let node_chanmgrs = create_node_chanmgrs(2, &node_cfgs, &[None, None]);
+ let nodes = create_network(2, &node_cfgs, &node_chanmgrs);
+
+ create_announced_chan_between_nodes_with_value(&nodes, 0, 1, 10_000_000, 1_000_000_000);
+
+ let alice = &nodes[0]; // recipient (offer creator)
+ let alice_id = alice.node.get_our_node_id();
+ let bob = &nodes[1]; // payer
+ let bob_id = bob.node.get_our_node_id();
+
+ // Alice creates an offer
+ let offer = alice.node
+ .create_offer_builder().unwrap()
+ .amount_msats(10_000_000)
+ .build().unwrap();
+
+ // Bob initiates payment
+ let payment_id = PaymentId([1; 32]);
+ bob.node.pay_for_offer(&offer, None, payment_id, Default::default()).unwrap();
+ expect_recent_payment!(bob, RecentPaymentDetails::AwaitingInvoice, payment_id);
+
+ // Bob sends invoice request to Alice
+ let onion_message = bob.onion_messenger.next_onion_message_for_peer(alice_id).unwrap();
+ alice.onion_messenger.handle_onion_message(bob_id, &onion_message);
+
+ let (invoice_request, _) = extract_invoice_request(alice, &onion_message);
+
+ // Alice sends invoice back to Bob
+ let onion_message = alice.onion_messenger.next_onion_message_for_peer(bob_id).unwrap();
+ bob.onion_messenger.handle_onion_message(alice_id, &onion_message);
+
+ let (invoice, _) = extract_invoice(bob, &onion_message);
+ assert_eq!(invoice.amount_msats(), 10_000_000);
+
+ // Extract the payment_id from Bob's reply path context. In a real wallet it would be
+ // persisted alongside the payment for later payer proof creation.
+ let context_payment_id = extract_payer_context(bob, &onion_message);
+ assert_eq!(context_payment_id, payment_id);
+
+ // Route the payment
+ route_bolt12_payment(bob, &[alice], &invoice);
+ expect_recent_payment!(bob, RecentPaymentDetails::Pending, payment_id);
+
+ // Get the payment preimage from Alice's PaymentClaimable event and claim it.
+ // In a real wallet, the payer receives the preimage via Event::PaymentSent after the
+ // recipient claims. For the test, we extract it from the recipient's claimable event.
+ let payment_preimage = match get_event!(alice, Event::PaymentClaimable) {
+ Event::PaymentClaimable { purpose, .. } => {
+ match &purpose {
+ PaymentPurpose::Bolt12OfferPayment { payment_context, .. } => {
+ assert_eq!(payment_context.offer_id, offer.id());
+ assert_eq!(
+ payment_context.invoice_request.payer_signing_pubkey,
+ invoice_request.payer_signing_pubkey(),
+ );
+ },
+ _ => panic!("Expected Bolt12OfferPayment purpose"),
+ }
+ purpose.preimage().unwrap()
+ },
+ _ => panic!("Expected Event::PaymentClaimable"),
+ };
+
+ let paid_invoice = claim_payment(bob, &[alice], payment_preimage).unwrap();
+ expect_recent_payment!(bob, RecentPaymentDetails::Fulfilled, payment_id);
+
+ // The paid invoice is carried so the payer can re-derive their signing key (from the invoice's
+ // own payer metadata) when building a payer proof.
+ assert!(paid_invoice.bolt12_invoice().is_some());
+
+ // Regression guard: the `Event::PaymentSent` container persists the paid invoice and reads it
+ // back. Round-tripping the event must preserve the invoice.
+ let payment_sent = Event::PaymentSent {
+ payment_id: Some(payment_id),
+ payment_preimage,
+ payment_hash: invoice.payment_hash(),
+ amount_msat: Some(10_000_000),
+ fee_paid_msat: None,
+ bolt12_invoice: Some(paid_invoice.clone()),
+ };
+ let encoded = payment_sent.encode();
+ let decoded = Event::read(&mut &encoded[..]).unwrap().unwrap();
+ assert_eq!(decoded, payment_sent);
+ match decoded {
+ Event::PaymentSent { bolt12_invoice: Some(decoded_invoice), .. } => {
+ assert!(decoded_invoice.bolt12_invoice().is_some());
+ },
+ _ => panic!("expected a PaymentSent event carrying a paid invoice"),
+ }
+
+ // --- Payer Proof Creation ---
+ // Bob (the payer) creates a proof-of-payment with selective disclosure, end to end from the
+ // invoice he actually paid. The negative paths (`PreimageMismatch`, `KeyDerivationFailed`) are
+ // covered by the unit tests in `offers::payer_proof::tests`.
+ let expanded_key = bob.keys_manager.get_expanded_key();
+ let secp_ctx = Secp256k1::new();
+ let payer_proof = paid_invoice.prove_payer_derived(
+ payment_preimage, &expanded_key, payment_id, &secp_ctx,
+ ).unwrap()
+ .include_offer_description()
+ .include_invoice_amount()
+ .include_invoice_created_at()
+ .build_and_sign()
+ .unwrap();
+
+ // The proof binds the payment Bob actually made.
+ assert_eq!(payer_proof.payment_preimage(), payment_preimage);
+ assert_eq!(payer_proof.payment_hash(), invoice.payment_hash());
+
+ // Parsing the bech32 string back re-runs verification (preimage, invoice and proof signatures),
+ // just as a third-party verifier would.
+ let encoded = payer_proof.to_string();
+ let verified: PayerProof = encoded.parse().unwrap();
+ assert_eq!(verified.bytes(), payer_proof.bytes());
+ assert_eq!(verified.to_string(), encoded);
+
+ // The verified proof binds the same payment and preserves every disclosed field.
+ assert_eq!(verified.payment_preimage(), payment_preimage);
+ assert_eq!(verified.payment_hash(), invoice.payment_hash());
+ assert_eq!(verified.payer_signing_pubkey(), invoice_request.payer_signing_pubkey());
+ assert_eq!(verified.issuer_signing_pubkey(), invoice.signing_pubkey());
+ assert_eq!(verified.invoice_amount_msats(), Some(invoice.amount_msats()));
+ assert_eq!(verified.invoice_created_at(), Some(invoice.created_at()));
+ assert_eq!(
+ verified.offer_description().map(|desc| desc.to_string()),
+ offer.description().map(|desc| desc.to_string()),
+ );
+}
diff --git a/lightning/src/ln/outbound_payment.rs b/lightning/src/ln/outbound_payment.rs
index 20b594a..c66166d 100644
--- a/lightning/src/ln/outbound_payment.rs
+++ b/lightning/src/ln/outbound_payment.rs
@@ -2892,6 +2892,7 @@ mod tests {
use crate::offers::invoice_request::InvoiceRequest;
use crate::offers::nonce::Nonce;
use crate::offers::offer::OfferBuilder;
+ use crate::offers::payer_proof::PaidBolt12Invoice;
use crate::offers::test_utils::*;
use crate::routing::gossip::NetworkGraph;
use crate::routing::router::{
@@ -2902,10 +2903,13 @@ mod tests {
use crate::types::features::{Bolt12InvoiceFeatures, ChannelFeatures, NodeFeatures};
use crate::types::payment::{PaymentHash, PaymentPreimage};
use crate::util::errors::APIError;
- use crate::util::hash_tables::new_hash_map;
+ use crate::util::hash_tables::{new_hash_map, new_hash_set};
use crate::util::logger::WithContext;
+ use crate::util::ser::{MaybeReadable, Writeable};
use crate::util::test_utils;
+ use super::PaymentAttempts;
+
use alloc::collections::VecDeque;
#[test]
@@ -3472,6 +3476,62 @@ mod tests {
assert!(pending_events.lock().unwrap().is_empty());
}
+ #[test]
+ fn retryable_payment_round_trips_bolt12_invoice() {
+ // A `Retryable` payment serializes its `bolt12_invoice` and reads it back. This guards that
+ // the paid invoice (needed to build payer proofs on retried paths) survives the round-trip.
+ let secp_ctx = Secp256k1::new();
+ let expanded_key = ExpandedKey::new([42; 32]);
+ let nonce = Nonce([7; 16]);
+ let payment_id = PaymentId([3; 32]);
+
+ let invoice = OfferBuilder::new(recipient_pubkey())
+ .amount_msats(1000)
+ .build()
+ .unwrap()
+ .request_invoice(&expanded_key, nonce, &secp_ctx, payment_id)
+ .unwrap()
+ .build_and_sign()
+ .unwrap()
+ .respond_with_no_std(payment_paths(), payment_hash(), now())
+ .unwrap()
+ .build()
+ .unwrap()
+ .sign(recipient_sign)
+ .unwrap();
+
+ let mut session_privs = new_hash_set();
+ session_privs.insert([1; 32]);
+ let payment = PendingOutboundPayment::Retryable {
+ retry_strategy: Some(Retry::Attempts(0)),
+ attempts: PaymentAttempts::new(),
+ payment_params: None,
+ session_privs,
+ payment_hash: payment_hash(),
+ payment_secret: None,
+ payment_metadata: None,
+ keysend_preimage: None,
+ invoice_request: None,
+ bolt12_invoice: Some(PaidBolt12Invoice::Bolt12Invoice(invoice)),
+ custom_tlvs: Vec::new(),
+ pending_amt_msat: 1000,
+ pending_fee_msat: None,
+ total_msat: 1000,
+ onion_total_msat: 1000,
+ starting_block_height: 0,
+ remaining_max_total_routing_fee_msat: None,
+ };
+
+ let encoded = payment.encode();
+ let decoded = PendingOutboundPayment::read(&mut &encoded[..]).unwrap().unwrap();
+ match decoded {
+ PendingOutboundPayment::Retryable { bolt12_invoice, .. } => {
+ assert!(matches!(bolt12_invoice, Some(PaidBolt12Invoice::Bolt12Invoice(_))));
+ },
+ _ => panic!("expected a Retryable payment"),
+ }
+ }
+
#[rustfmt::skip]
fn dummy_invoice_request() -> InvoiceRequest {
let expanded_key = ExpandedKey::new([42; 32]);
Why this scored 25/100
Community notes
Notes can correct, qualify, or add evidence to the AI analysis. Every note shown here has been validated by a human moderator.
The AI analysis stands alone for now. Submit a note if you can add evidence or important context.