diff --git a/CHANGELOG.md b/CHANGELOG.md index 0e9236489..8853e6361 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 c1a926f2f..be5fdc808 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 8546af0dd..1fbc5066a 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 0dc79758d..9c5d33863 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 15ab251f0..0e994d67a 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 fd75322ce..1ac6103be 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 7fe26509a..0333fe006 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);