From d848ff591ed5ec2cbd853e1e537cb4e77b1676fd Mon Sep 17 00:00:00 2001 From: Vincenzo Palazzo Date: Wed, 12 Aug 2026 12:42:15 +0200 Subject: [PATCH] Add BOLT 12 payer proof support Expose `Bolt12Payment::create_payer_proof`, which builds a BOLT 12 payer proof for a payment this node made, with `PayerProofOptions` controlling which optional invoice fields are selectively disclosed. The method is stateless: the payment id, payment preimage, and paid invoice are all taken from `Event::PaymentSuccessful` and handed back to us by the caller, so nothing is read from or written to the payment store. The node only contributes the expanded key needed to re-derive the payer signing key, which is the one part users can't supply themselves. Keeping it stateless means we don't have to decide up front where paid BOLT 12 invoices should eventually live, and leaves us free to change or drop this API once the verification side is worked out. Payments settled via a static invoice, i.e., async payments, can't be proven this way and are rejected with `PayerProofUnavailable`. This commit was written with AI assistance. Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 5 ++ bindings/ldk_node.udl | 2 + src/error.rs | 6 ++ src/ffi/types.rs | 144 +++++++++++++++++++++++++++++++- src/payment/bolt12.rs | 117 +++++++++++++++++++++++++- src/payment/mod.rs | 2 +- tests/integration_tests_rust.rs | 39 +++++++-- 7 files changed, 307 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0e92364898..8853e63610 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,6 +32,11 @@ `ChannelTypeFeatures`. - `Config::anchor_channels_config` is no longer optional, hence anchor channels can no longer be disabled. We still negotiate legacy channels if the peer does not support anchor channels. +- `Bolt12Payment::create_payer_proof` allows building a BOLT 12 payer proof for a payment made by + this node, with `PayerProofOptions` controlling which optional invoice fields are selectively + disclosed. The method is stateless: the payment id, preimage, and invoice are taken from + `Event::PaymentSuccessful` and nothing is persisted. Payments settled via a static invoice, + i.e., async payments, don't support payer proofs. (#1045) ## Bug Fixes and Improvements - Building a fresh node against a Bitcoin Core RPC or REST chain source that fails to return the diff --git a/bindings/ldk_node.udl b/bindings/ldk_node.udl index c1a926f2fd..be5fdc8084 100644 --- a/bindings/ldk_node.udl +++ b/bindings/ldk_node.udl @@ -206,6 +206,7 @@ enum NodeError { "FeerateEstimationUpdateTimeout", "WalletOperationFailed", "WalletOperationTimeout", + "PayerProofCreationFailed", "OnchainTxSigningFailed", "TxSyncFailed", "TxSyncTimeout", @@ -247,6 +248,7 @@ enum NodeError { "LnurlAuthTimeout", "InvalidLnurl", "ChainSourceNotSupported", + "InvalidPayerProof", }; typedef dictionary NodeStatus; diff --git a/src/error.rs b/src/error.rs index 8546af0dd2..1fbc5066a3 100644 --- a/src/error.rs +++ b/src/error.rs @@ -57,6 +57,8 @@ pub enum Error { WalletOperationFailed, /// A wallet operation timed out. WalletOperationTimeout, + /// Creating a payer proof failed. + PayerProofCreationFailed, /// A signing operation for transaction failed. OnchainTxSigningFailed, /// A transaction sync operation failed. @@ -139,6 +141,8 @@ pub enum Error { InvalidLnurl, /// The configured chain source is not supported. ChainSourceNotSupported, + /// The provided payer proof is invalid. + InvalidPayerProof, } impl fmt::Display for Error { @@ -170,6 +174,7 @@ impl fmt::Display for Error { }, Self::WalletOperationFailed => write!(f, "Failed to conduct wallet operation."), Self::WalletOperationTimeout => write!(f, "A wallet operation timed out."), + Self::PayerProofCreationFailed => write!(f, "Failed to create payer proof."), Self::OnchainTxSigningFailed => write!(f, "Failed to sign given transaction."), Self::TxSyncFailed => write!(f, "Failed to sync transactions."), Self::TxSyncTimeout => write!(f, "Syncing transactions timed out."), @@ -227,6 +232,7 @@ impl fmt::Display for Error { Self::ChainSourceNotSupported => { write!(f, "The configured chain source is not supported.") }, + Self::InvalidPayerProof => write!(f, "The provided payer proof is invalid."), } } } diff --git a/src/ffi/types.rs b/src/ffi/types.rs index 0dc79758d6..9c5d338631 100644 --- a/src/ffi/types.rs +++ b/src/ffi/types.rs @@ -23,7 +23,6 @@ use bitcoin::hashes::Hash; use bitcoin::secp256k1::PublicKey; pub use bitcoin::{Address, BlockHash, Network, OutPoint, ScriptBuf, Txid}; pub use lightning::chain::channelmonitor::BalanceSource; -use lightning::events::PaidBolt12Invoice as LdkPaidBolt12Invoice; pub use lightning::events::{ClosureReason, PaymentFailureReason}; use lightning::ln::channel_state::{ChannelShutdownState, CounterpartyForwardingInfo}; use lightning::ln::channelmanager::PaymentId; @@ -32,6 +31,9 @@ pub use lightning::ln::types::ChannelId; use lightning::offers::invoice::Bolt12Invoice as LdkBolt12Invoice; pub use lightning::offers::offer::OfferId; use lightning::offers::offer::{Amount as LdkAmount, Offer as LdkOffer}; +use lightning::offers::payer_proof::{ + PaidBolt12Invoice as LdkPaidBolt12Invoice, PayerProof as LdkPayerProof, +}; use lightning::offers::refund::Refund as LdkRefund; use lightning::offers::static_invoice::StaticInvoice as LdkStaticInvoice; use lightning::onion_message::dns_resolution::HumanReadableName as LdkHumanReadableName; @@ -840,6 +842,17 @@ pub enum PaidBolt12Invoice { Static(Arc), } +impl PaidBolt12Invoice { + /// Returns the [`Bolt12Invoice`] if the payment was for a standard BOLT 12 invoice, and + /// `None` for a static invoice, i.e., an async payment, which can't be proven. + pub fn bolt12_invoice(&self) -> Option> { + match self { + PaidBolt12Invoice::Bolt12(invoice) => Some(Arc::clone(invoice)), + PaidBolt12Invoice::Static(_) => None, + } + } +} + impl From for PaidBolt12Invoice { fn from(ldk: LdkPaidBolt12Invoice) -> Self { match ldk { @@ -881,6 +894,135 @@ impl Readable for PaidBolt12Invoice { } } +/// A cryptographic proof that a BOLT12 invoice was paid by this node. +/// +/// Hand the encoded form, via [`Self::bytes`] or [`Self::as_string`], to whoever needs to verify +/// it. The remaining accessors expose the fields that were selectively disclosed when the proof +/// was created. +#[derive(Debug, Clone, uniffi::Object)] +#[uniffi::export(Debug, Display)] +pub struct PayerProof { + pub(crate) inner: LdkPayerProof, +} + +#[uniffi::export] +impl PayerProof { + #[uniffi::constructor] + pub fn from_bytes(proof_bytes: Vec) -> Result { + let inner = LdkPayerProof::try_from(proof_bytes).map_err(|_| Error::InvalidPayerProof)?; + Ok(Self { inner }) + } + + /// Parses a payer proof from its bech32-encoded string form, as returned by + /// [`Self::as_string`]. + #[uniffi::constructor] + pub fn from_str(proof_str: &str) -> Result { + proof_str.parse() + } + + /// The payment preimage proving the payment completed. + pub fn payment_preimage(&self) -> PaymentPreimage { + self.inner.payment_preimage() + } + + /// The payment hash committed to by the invoice and proven by the preimage. + pub fn payment_hash(&self) -> PaymentHash { + self.inner.payment_hash() + } + + /// The public key of the payer that authorized the payment. + pub fn payer_signing_pubkey(&self) -> PublicKey { + self.inner.payer_signing_pubkey() + } + + /// The issuer signing public key committed to by the invoice. + pub fn issuer_signing_pubkey(&self) -> PublicKey { + self.inner.issuer_signing_pubkey() + } + + /// The invoice signature bytes. + pub fn invoice_signature(&self) -> Vec { + self.inner.invoice_signature().as_ref().to_vec() + } + + /// The proof signature bytes. + pub fn proof_signature(&self) -> Vec { + self.inner.proof_signature().as_ref().to_vec() + } + + /// The offer description, if it was disclosed in the proof. + pub fn offer_description(&self) -> Option { + self.inner.offer_description().map(|value| value.to_string()) + } + + /// The offer issuer, if it was disclosed in the proof. + pub fn offer_issuer(&self) -> Option { + self.inner.offer_issuer().map(|value| value.to_string()) + } + + /// The invoice amount in millisatoshis, if it was disclosed in the proof. + pub fn invoice_amount_msats(&self) -> Option { + self.inner.invoice_amount_msats() + } + + /// The invoice creation time, in seconds since the UNIX epoch, if it was disclosed in the + /// proof. + pub fn invoice_created_at(&self) -> Option { + self.inner.invoice_created_at().map(|value| value.as_secs()) + } + + /// The optional note attached to the proof. + pub fn proof_note(&self) -> Option { + self.inner.proof_note().map(|value| value.to_string()) + } + + /// The Merkle root committed to by the proof. + pub fn merkle_root(&self) -> Vec { + self.inner.merkle_root().to_byte_array().to_vec() + } + + /// The raw TLV bytes of the proof. + pub fn bytes(&self) -> Vec { + self.inner.bytes().to_vec() + } + + /// The bech32-encoded string form of the proof. + pub fn as_string(&self) -> String { + self.inner.to_string() + } +} + +impl From for PayerProof { + fn from(inner: LdkPayerProof) -> Self { + Self { inner } + } +} + +impl std::str::FromStr for PayerProof { + type Err = Error; + + fn from_str(proof_str: &str) -> Result { + proof_str + .parse::() + .map(|proof| PayerProof { inner: proof }) + .map_err(|_| Error::InvalidPayerProof) + } +} + +impl Deref for PayerProof { + type Target = LdkPayerProof; + + fn deref(&self) -> &Self::Target { + &self.inner + } +} + +impl std::fmt::Display for PayerProof { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.inner) + } +} + uniffi::custom_type!(OfferId, String, { remote, try_lift: |val| { diff --git a/src/payment/bolt12.rs b/src/payment/bolt12.rs index 15ab251f07..0e994d67ac 100644 --- a/src/payment/bolt12.rs +++ b/src/payment/bolt12.rs @@ -18,10 +18,14 @@ use lightning::ln::channelmanager::{OptionalOfferPaymentParams, PaymentId}; use lightning::ln::outbound_payment::Retry; use lightning::offers::offer::{Amount, Offer as LdkOffer, OfferFromHrn, Quantity}; use lightning::offers::parse::Bolt12SemanticError; +use lightning::offers::payer_proof::PaidBolt12Invoice as LdkPaidBolt12Invoice; +#[cfg(not(feature = "uniffi"))] +use lightning::offers::payer_proof::PayerProof as LdkPayerProof; use lightning::routing::router::RouteParametersConfig; -use lightning::sign::EntropySource; +use lightning::sign::{EntropySource, NodeSigner}; #[cfg(feature = "uniffi")] use lightning::util::ser::{Readable, Writeable}; +use lightning_types::payment::PaymentPreimage; use lightning_types::string::UntrustedString; use crate::config::{AsyncPaymentsRole, Config, LDK_PAYMENT_RETRY_TIMEOUT}; @@ -52,6 +56,36 @@ type HumanReadableName = lightning::onion_message::dns_resolution::HumanReadable #[cfg(feature = "uniffi")] type HumanReadableName = Arc; +#[cfg(not(feature = "uniffi"))] +type PayerProof = LdkPayerProof; +#[cfg(feature = "uniffi")] +type PayerProof = Arc; + +/// Options controlling which optional fields are disclosed in a [BOLT 12] payer proof. +/// +/// A payer proof always commits to the payer id, the payment hash, and the issuer signing +/// pubkey, and additionally discloses the invoice features whenever the invoice carries any. +/// Everything else is disclosed only if requested here, allowing to reveal just as much of the +/// invoice as the verifier needs to see. +/// +/// [BOLT 12]: https://github.com/lightning/bolts/blob/master/12-offer-encoding.md +#[derive(Clone, Debug, PartialEq, Eq, Default)] +#[cfg_attr(feature = "uniffi", derive(uniffi::Record))] +pub struct PayerProofOptions { + /// An optional note to attach to the payer proof itself. + pub note: Option, + /// Whether to disclose the offer description. + pub include_offer_description: bool, + /// Whether to disclose the offer issuer. + pub include_offer_issuer: bool, + /// Whether to disclose the invoice amount. + pub include_invoice_amount: bool, + /// Whether to disclose the invoice creation timestamp. + pub include_invoice_created_at: bool, + /// Additional TLV types to disclose, for fields not covered by the flags above. + pub extra_tlv_types: Vec, +} + /// A payment handler allowing to create and pay [BOLT 12] offers and refunds. /// /// Should be retrieved by calling [`Node::bolt12_payment`]. @@ -389,6 +423,87 @@ impl Bolt12Payment { Ok(payment_id) } + /// Creates a [BOLT 12] payer proof for a payment this node made. + /// + /// A payer proof lets the payer demonstrate to a third party that they paid a particular + /// [BOLT 12] invoice, disclosing only the invoice fields they choose to reveal via + /// [`PayerProofOptions`]. + /// + /// All inputs are taken straight from [`Event::PaymentSuccessful`]: pass its `payment_id` and + /// `payment_preimage`, plus the [`Bolt12Invoice`] out of its `bolt12_invoice` field. Nothing + /// is read from or written to the payment store, so it's up to you to hold on to the invoice + /// if you want to build a proof later on. + /// + /// Note that payments settled via a static invoice, i.e., async payments, can't be proven this + /// way, which is why this takes a [`Bolt12Invoice`] rather than the event's + /// [`PaidBolt12Invoice`]: those payments simply won't yield one. + /// + /// [BOLT 12]: https://github.com/lightning/bolts/blob/master/12-offer-encoding.md + /// [`Event::PaymentSuccessful`]: crate::Event::PaymentSuccessful + /// [`Bolt12Invoice`]: lightning::offers::invoice::Bolt12Invoice + /// [`PaidBolt12Invoice`]: lightning::offers::payer_proof::PaidBolt12Invoice + pub fn create_payer_proof( + &self, payment_id: PaymentId, payment_preimage: PaymentPreimage, invoice: &Bolt12Invoice, + options: Option, + ) -> Result { + let invoice = maybe_deref(invoice); + let paid_invoice = LdkPaidBolt12Invoice::Bolt12Invoice(invoice.clone()); + + let options = options.unwrap_or_default(); + let expanded_key = self.keys_manager.get_expanded_key(); + let secp_ctx = bitcoin::secp256k1::Secp256k1::new(); + + let mut builder = paid_invoice + .prove_payer_derived(payment_preimage, &expanded_key, payment_id, &secp_ctx) + .map_err(|e| { + log_error!( + self.logger, + "Failed to initialize payer proof builder for {}: {:?}", + payment_id, + e + ); + Error::PayerProofCreationFailed + })?; + + for tlv_type in options.extra_tlv_types { + builder = builder.include_type(tlv_type).map_err(|e| { + log_error!( + self.logger, + "Failed to include TLV {} in payer proof for {}: {:?}", + tlv_type, + payment_id, + e + ); + Error::PayerProofCreationFailed + })?; + } + + if options.include_offer_description { + builder = builder.include_offer_description(); + } + if options.include_offer_issuer { + builder = builder.include_offer_issuer(); + } + if options.include_invoice_amount { + builder = builder.include_invoice_amount(); + } + if options.include_invoice_created_at { + builder = builder.include_invoice_created_at(); + } + if let Some(note) = options.note { + builder = builder.with_proof_note(note); + } + + let proof = builder.build_and_sign().map_err(|e| { + log_error!(self.logger, "Failed to build payer proof for {}: {:?}", payment_id, e); + Error::PayerProofCreationFailed + })?; + + log_info!(self.logger, "Created payer proof for payment {}", payment_id); + + Ok(maybe_wrap(proof)) + } + /// Returns a payable offer that can be used to request and receive a payment of the amount /// given. pub fn receive( diff --git a/src/payment/mod.rs b/src/payment/mod.rs index fd75322ceb..1ac6103bea 100644 --- a/src/payment/mod.rs +++ b/src/payment/mod.rs @@ -18,7 +18,7 @@ mod unified; pub use bolt11::Bolt11Payment; pub(crate) use bolt11::PaymentMetadata; -pub use bolt12::Bolt12Payment; +pub use bolt12::{Bolt12Payment, PayerProofOptions}; pub use onchain::OnchainPayment; pub(crate) use pending_payment_store::{FundingTxCandidate, PendingPaymentDetails}; pub use spontaneous::SpontaneousPayment; diff --git a/tests/integration_tests_rust.rs b/tests/integration_tests_rust.rs index 7fe26509a5..0333fe006b 100644 --- a/tests/integration_tests_rust.rs +++ b/tests/integration_tests_rust.rs @@ -38,8 +38,8 @@ use ldk_node::config::{ use ldk_node::entropy::NodeEntropy; use ldk_node::liquidity::LSPS2ServiceConfig; use ldk_node::payment::{ - ConfirmationStatus, PaymentDetails, PaymentDirection, PaymentKind, PaymentStatus, - TransactionType, UnifiedPaymentResult, + ConfirmationStatus, PayerProofOptions, PaymentDetails, PaymentDirection, PaymentKind, + PaymentStatus, TransactionType, UnifiedPaymentResult, }; use ldk_node::{BuildError, Builder, Event, Node, NodeError, ReserveType}; use lightning::ln::channelmanager::PaymentId; @@ -2592,18 +2592,47 @@ async fn simple_bolt12_send_receive() { .unwrap(); let event = node_a.next_event_async().await; - match event { - ref e @ Event::PaymentSuccessful { payment_id: ref evt_id, ref bolt12_invoice, .. } => { + let (invoice, payment_preimage) = match event { + ref e @ Event::PaymentSuccessful { + payment_id: ref evt_id, + ref bolt12_invoice, + ref payment_preimage, + .. + } => { println!("{} got event {:?}", node_a.node_id(), e); assert_eq!(*evt_id, payment_id); assert!( bolt12_invoice.is_some(), "bolt12_invoice should be present for BOLT12 payments" ); + let invoice = bolt12_invoice.as_ref().unwrap().bolt12_invoice().unwrap().clone(); + let captured = (invoice, payment_preimage.unwrap()); node_a.event_handled().unwrap(); + captured }, ref e => panic!("{} got unexpected event!: {:?}", "node_a", e), - } + }; + + // The payer proof is built purely from what the event handed us -- nothing is read back out + // of the payment store. + let expected_proof_note = "Paid in full".to_string(); + let options = PayerProofOptions { + note: Some(expected_proof_note.clone()), + include_offer_description: true, + include_invoice_amount: true, + ..Default::default() + }; + let payer_proof = node_a + .bolt12_payment() + .create_payer_proof(payment_id, payment_preimage, &invoice, Some(options)) + .unwrap(); + assert_eq!(payer_proof.payment_preimage(), payment_preimage); + assert_eq!(payer_proof.invoice_amount_msats(), Some(expected_amount_msat)); + assert_eq!(payer_proof.proof_note().map(|n| n.to_string()), Some(expected_proof_note)); + assert!(payer_proof.offer_description().is_some()); + // Fields we didn't ask to disclose stay absent. + assert!(payer_proof.offer_issuer().is_none()); + assert!(payer_proof.invoice_created_at().is_none()); let node_a_payments = node_a.list_payments_with_filter(|p| matches!(p.kind, PaymentKind::Bolt12Offer { .. })); assert_eq!(node_a_payments.len(), 1);