Skip to content

Commit aa9f3fb

Browse files
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 <noreply@anthropic.com>
1 parent 83164c9 commit aa9f3fb

7 files changed

Lines changed: 238 additions & 8 deletions

File tree

CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,11 @@
3232
`ChannelTypeFeatures`.
3333
- `Config::anchor_channels_config` is no longer optional, hence anchor channels can no longer be
3434
disabled. We still negotiate legacy channels if the peer does not support anchor channels.
35+
- `Bolt12Payment::create_payer_proof` allows building a BOLT 12 payer proof for a payment made by
36+
this node, with `PayerProofOptions` controlling which optional invoice fields are selectively
37+
disclosed. The method is stateless: the payment id, preimage, and invoice are taken from
38+
`Event::PaymentSuccessful` and nothing is persisted. Payments settled via a static invoice,
39+
i.e., async payments, don't support payer proofs. (#1045)
3540

3641
## Bug Fixes and Improvements
3742
- Building a fresh node against a Bitcoin Core RPC or REST chain source that fails to return the

bindings/ldk_node.udl

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -206,6 +206,7 @@ enum NodeError {
206206
"FeerateEstimationUpdateTimeout",
207207
"WalletOperationFailed",
208208
"WalletOperationTimeout",
209+
"PayerProofCreationFailed",
209210
"OnchainTxSigningFailed",
210211
"TxSyncFailed",
211212
"TxSyncTimeout",
@@ -247,6 +248,7 @@ enum NodeError {
247248
"LnurlAuthTimeout",
248249
"InvalidLnurl",
249250
"ChainSourceNotSupported",
251+
"InvalidPayerProof",
250252
};
251253

252254
typedef dictionary NodeStatus;

src/error.rs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,8 @@ pub enum Error {
5757
WalletOperationFailed,
5858
/// A wallet operation timed out.
5959
WalletOperationTimeout,
60+
/// Creating a payer proof failed.
61+
PayerProofCreationFailed,
6062
/// A signing operation for transaction failed.
6163
OnchainTxSigningFailed,
6264
/// A transaction sync operation failed.
@@ -139,6 +141,8 @@ pub enum Error {
139141
InvalidLnurl,
140142
/// The configured chain source is not supported.
141143
ChainSourceNotSupported,
144+
/// The provided payer proof is invalid.
145+
InvalidPayerProof,
142146
}
143147

144148
impl fmt::Display for Error {
@@ -170,6 +174,7 @@ impl fmt::Display for Error {
170174
},
171175
Self::WalletOperationFailed => write!(f, "Failed to conduct wallet operation."),
172176
Self::WalletOperationTimeout => write!(f, "A wallet operation timed out."),
177+
Self::PayerProofCreationFailed => write!(f, "Failed to create payer proof."),
173178
Self::OnchainTxSigningFailed => write!(f, "Failed to sign given transaction."),
174179
Self::TxSyncFailed => write!(f, "Failed to sync transactions."),
175180
Self::TxSyncTimeout => write!(f, "Syncing transactions timed out."),
@@ -227,6 +232,7 @@ impl fmt::Display for Error {
227232
Self::ChainSourceNotSupported => {
228233
write!(f, "The configured chain source is not supported.")
229234
},
235+
Self::InvalidPayerProof => write!(f, "The provided payer proof is invalid."),
230236
}
231237
}
232238
}

src/ffi/types.rs

Lines changed: 90 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,6 @@ use bitcoin::hashes::Hash;
2323
use bitcoin::secp256k1::PublicKey;
2424
pub use bitcoin::{Address, BlockHash, Network, OutPoint, ScriptBuf, Txid};
2525
pub use lightning::chain::channelmonitor::BalanceSource;
26-
use lightning::events::PaidBolt12Invoice as LdkPaidBolt12Invoice;
2726
pub use lightning::events::{ClosureReason, PaymentFailureReason};
2827
use lightning::ln::channel_state::{ChannelShutdownState, CounterpartyForwardingInfo};
2928
use lightning::ln::channelmanager::PaymentId;
@@ -32,6 +31,9 @@ pub use lightning::ln::types::ChannelId;
3231
use lightning::offers::invoice::Bolt12Invoice as LdkBolt12Invoice;
3332
pub use lightning::offers::offer::OfferId;
3433
use lightning::offers::offer::{Amount as LdkAmount, Offer as LdkOffer};
34+
use lightning::offers::payer_proof::{
35+
PaidBolt12Invoice as LdkPaidBolt12Invoice, PayerProof as LdkPayerProof,
36+
};
3537
use lightning::offers::refund::Refund as LdkRefund;
3638
use lightning::offers::static_invoice::StaticInvoice as LdkStaticInvoice;
3739
use lightning::onion_message::dns_resolution::HumanReadableName as LdkHumanReadableName;
@@ -881,6 +883,93 @@ impl Readable for PaidBolt12Invoice {
881883
}
882884
}
883885

886+
/// A cryptographic proof that a BOLT12 invoice was paid by this node.
887+
///
888+
/// Hand the encoded form, via [`Self::bytes`] or [`Self::as_string`], to whoever needs to verify
889+
/// it. The accessors below expose the fields that were selectively disclosed when the proof was
890+
/// created; the signatures and Merkle root needed to actually verify a proof aren't surfaced
891+
/// yet, as the verification API is still being designed.
892+
#[derive(Debug, Clone, uniffi::Object)]
893+
#[uniffi::export(Debug, Display)]
894+
pub struct PayerProof {
895+
pub(crate) inner: LdkPayerProof,
896+
}
897+
898+
#[uniffi::export]
899+
impl PayerProof {
900+
#[uniffi::constructor]
901+
pub fn from_bytes(proof_bytes: Vec<u8>) -> Result<Self, Error> {
902+
let inner = LdkPayerProof::try_from(proof_bytes).map_err(|_| Error::InvalidPayerProof)?;
903+
Ok(Self { inner })
904+
}
905+
906+
/// The payment preimage proving the payment completed.
907+
pub fn payment_preimage(&self) -> PaymentPreimage {
908+
self.inner.payment_preimage()
909+
}
910+
911+
/// The payment hash committed to by the invoice and proven by the preimage.
912+
pub fn payment_hash(&self) -> PaymentHash {
913+
self.inner.payment_hash()
914+
}
915+
916+
/// The offer description, if it was disclosed in the proof.
917+
pub fn offer_description(&self) -> Option<String> {
918+
self.inner.offer_description().map(|value| value.to_string())
919+
}
920+
921+
/// The offer issuer, if it was disclosed in the proof.
922+
pub fn offer_issuer(&self) -> Option<String> {
923+
self.inner.offer_issuer().map(|value| value.to_string())
924+
}
925+
926+
/// The invoice amount in millisatoshis, if it was disclosed in the proof.
927+
pub fn invoice_amount_msats(&self) -> Option<u64> {
928+
self.inner.invoice_amount_msats()
929+
}
930+
931+
/// The invoice creation time, in seconds since the UNIX epoch, if it was disclosed in the
932+
/// proof.
933+
pub fn invoice_created_at(&self) -> Option<u64> {
934+
self.inner.invoice_created_at().map(|value| value.as_secs())
935+
}
936+
937+
/// The optional note attached to the proof.
938+
pub fn proof_note(&self) -> Option<String> {
939+
self.inner.proof_note().map(|value| value.to_string())
940+
}
941+
942+
/// The raw TLV bytes of the proof.
943+
pub fn bytes(&self) -> Vec<u8> {
944+
self.inner.bytes().to_vec()
945+
}
946+
947+
/// The bech32-encoded string form of the proof.
948+
pub fn as_string(&self) -> String {
949+
self.inner.to_string()
950+
}
951+
}
952+
953+
impl From<LdkPayerProof> for PayerProof {
954+
fn from(inner: LdkPayerProof) -> Self {
955+
Self { inner }
956+
}
957+
}
958+
959+
impl Deref for PayerProof {
960+
type Target = LdkPayerProof;
961+
962+
fn deref(&self) -> &Self::Target {
963+
&self.inner
964+
}
965+
}
966+
967+
impl std::fmt::Display for PayerProof {
968+
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
969+
write!(f, "{}", self.inner)
970+
}
971+
}
972+
884973
uniffi::custom_type!(OfferId, String, {
885974
remote,
886975
try_lift: |val| {

src/payment/bolt12.rs

Lines changed: 100 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,10 +18,14 @@ use lightning::ln::channelmanager::{OptionalOfferPaymentParams, PaymentId};
1818
use lightning::ln::outbound_payment::Retry;
1919
use lightning::offers::offer::{Amount, Offer as LdkOffer, OfferFromHrn, Quantity};
2020
use lightning::offers::parse::Bolt12SemanticError;
21+
use lightning::offers::payer_proof::PaidBolt12Invoice as LdkPaidBolt12Invoice;
22+
#[cfg(not(feature = "uniffi"))]
23+
use lightning::offers::payer_proof::PayerProof as LdkPayerProof;
2124
use lightning::routing::router::RouteParametersConfig;
22-
use lightning::sign::EntropySource;
25+
use lightning::sign::{EntropySource, NodeSigner};
2326
#[cfg(feature = "uniffi")]
2427
use lightning::util::ser::{Readable, Writeable};
28+
use lightning_types::payment::PaymentPreimage;
2529
use lightning_types::string::UntrustedString;
2630

2731
use crate::config::{AsyncPaymentsRole, Config, LDK_PAYMENT_RETRY_TIMEOUT};
@@ -52,6 +56,33 @@ type HumanReadableName = lightning::onion_message::dns_resolution::HumanReadable
5256
#[cfg(feature = "uniffi")]
5357
type HumanReadableName = Arc<crate::ffi::HumanReadableName>;
5458

59+
#[cfg(not(feature = "uniffi"))]
60+
type PayerProof = LdkPayerProof;
61+
#[cfg(feature = "uniffi")]
62+
type PayerProof = Arc<crate::ffi::PayerProof>;
63+
64+
/// Options controlling which optional fields are disclosed in a [BOLT 12] payer proof.
65+
///
66+
/// A payer proof always commits to the payer id, the payment hash, and the issuer signing
67+
/// pubkey. Everything else is disclosed only if requested here, allowing to reveal just as much
68+
/// of the invoice as the verifier needs to see.
69+
///
70+
/// [BOLT 12]: https://github.com/lightning/bolts/blob/master/12-offer-encoding.md
71+
#[derive(Clone, Debug, PartialEq, Eq, Default)]
72+
#[cfg_attr(feature = "uniffi", derive(uniffi::Record))]
73+
pub struct PayerProofOptions {
74+
/// An optional note to attach to the payer proof itself.
75+
pub note: Option<String>,
76+
/// Whether to disclose the offer description.
77+
pub include_offer_description: bool,
78+
/// Whether to disclose the offer issuer.
79+
pub include_offer_issuer: bool,
80+
/// Whether to disclose the invoice amount.
81+
pub include_invoice_amount: bool,
82+
/// Whether to disclose the invoice creation timestamp.
83+
pub include_invoice_created_at: bool,
84+
}
85+
5586
/// A payment handler allowing to create and pay [BOLT 12] offers and refunds.
5687
///
5788
/// Should be retrieved by calling [`Node::bolt12_payment`].
@@ -389,6 +420,74 @@ impl Bolt12Payment {
389420
Ok(payment_id)
390421
}
391422

423+
/// Creates a [BOLT 12] payer proof for a payment this node made.
424+
///
425+
/// A payer proof lets the payer demonstrate to a third party that they paid a particular
426+
/// [BOLT 12] invoice, disclosing only the invoice fields they choose to reveal via
427+
/// [`PayerProofOptions`].
428+
///
429+
/// All inputs are taken straight from [`Event::PaymentSuccessful`]: pass its `payment_id` and
430+
/// `payment_preimage`, plus the [`Bolt12Invoice`] out of its `bolt12_invoice` field. Nothing
431+
/// is read from or written to the payment store, so it's up to you to hold on to the invoice
432+
/// if you want to build a proof later on.
433+
///
434+
/// Note that payments settled via a static invoice, i.e., async payments, can't be proven this
435+
/// way, which is why this takes a [`Bolt12Invoice`] rather than the event's
436+
/// [`PaidBolt12Invoice`]: those payments simply won't yield one.
437+
///
438+
/// [BOLT 12]: https://github.com/lightning/bolts/blob/master/12-offer-encoding.md
439+
/// [`Event::PaymentSuccessful`]: crate::Event::PaymentSuccessful
440+
/// [`Bolt12Invoice`]: lightning::offers::invoice::Bolt12Invoice
441+
/// [`PaidBolt12Invoice`]: lightning::offers::payer_proof::PaidBolt12Invoice
442+
pub fn create_payer_proof(
443+
&self, payment_id: PaymentId, payment_preimage: PaymentPreimage, invoice: &Bolt12Invoice,
444+
options: Option<PayerProofOptions>,
445+
) -> Result<PayerProof, Error> {
446+
let invoice = maybe_deref(invoice);
447+
let paid_invoice = LdkPaidBolt12Invoice::Bolt12Invoice(invoice.clone());
448+
449+
let options = options.unwrap_or_default();
450+
let expanded_key = self.keys_manager.get_expanded_key();
451+
let secp_ctx = bitcoin::secp256k1::Secp256k1::new();
452+
453+
let mut builder = paid_invoice
454+
.prove_payer_derived(payment_preimage, &expanded_key, payment_id, &secp_ctx)
455+
.map_err(|e| {
456+
log_error!(
457+
self.logger,
458+
"Failed to initialize payer proof builder for {}: {:?}",
459+
payment_id,
460+
e
461+
);
462+
Error::PayerProofCreationFailed
463+
})?;
464+
465+
if options.include_offer_description {
466+
builder = builder.include_offer_description();
467+
}
468+
if options.include_offer_issuer {
469+
builder = builder.include_offer_issuer();
470+
}
471+
if options.include_invoice_amount {
472+
builder = builder.include_invoice_amount();
473+
}
474+
if options.include_invoice_created_at {
475+
builder = builder.include_invoice_created_at();
476+
}
477+
if let Some(note) = options.note {
478+
builder = builder.with_proof_note(note);
479+
}
480+
481+
let proof = builder.build_and_sign().map_err(|e| {
482+
log_error!(self.logger, "Failed to build payer proof for {}: {:?}", payment_id, e);
483+
Error::PayerProofCreationFailed
484+
})?;
485+
486+
log_info!(self.logger, "Created payer proof for payment {}", payment_id);
487+
488+
Ok(maybe_wrap(proof))
489+
}
490+
392491
/// Returns a payable offer that can be used to request and receive a payment of the amount
393492
/// given.
394493
pub fn receive(

src/payment/mod.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ mod unified;
1818

1919
pub use bolt11::Bolt11Payment;
2020
pub(crate) use bolt11::PaymentMetadata;
21-
pub use bolt12::Bolt12Payment;
21+
pub use bolt12::{Bolt12Payment, PayerProofOptions};
2222
pub use onchain::OnchainPayment;
2323
pub(crate) use pending_payment_store::{FundingTxCandidate, PendingPaymentDetails};
2424
pub use spontaneous::SpontaneousPayment;

tests/integration_tests_rust.rs

Lines changed: 34 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -38,8 +38,8 @@ use ldk_node::config::{
3838
use ldk_node::entropy::NodeEntropy;
3939
use ldk_node::liquidity::LSPS2ServiceConfig;
4040
use ldk_node::payment::{
41-
ConfirmationStatus, PaymentDetails, PaymentDirection, PaymentKind, PaymentStatus,
42-
TransactionType, UnifiedPaymentResult,
41+
ConfirmationStatus, PayerProofOptions, PaymentDetails, PaymentDirection, PaymentKind,
42+
PaymentStatus, TransactionType, UnifiedPaymentResult,
4343
};
4444
use ldk_node::{BuildError, Builder, Event, Node, NodeError, ReserveType};
4545
use lightning::ln::channelmanager::PaymentId;
@@ -2592,18 +2592,47 @@ async fn simple_bolt12_send_receive() {
25922592
.unwrap();
25932593

25942594
let event = node_a.next_event_async().await;
2595-
match event {
2596-
ref e @ Event::PaymentSuccessful { payment_id: ref evt_id, ref bolt12_invoice, .. } => {
2595+
let (invoice, payment_preimage) = match event {
2596+
ref e @ Event::PaymentSuccessful {
2597+
payment_id: ref evt_id,
2598+
ref bolt12_invoice,
2599+
ref payment_preimage,
2600+
..
2601+
} => {
25972602
println!("{} got event {:?}", node_a.node_id(), e);
25982603
assert_eq!(*evt_id, payment_id);
25992604
assert!(
26002605
bolt12_invoice.is_some(),
26012606
"bolt12_invoice should be present for BOLT12 payments"
26022607
);
2608+
let invoice = bolt12_invoice.as_ref().unwrap().bolt12_invoice().unwrap().clone();
2609+
let captured = (invoice, payment_preimage.unwrap());
26032610
node_a.event_handled().unwrap();
2611+
captured
26042612
},
26052613
ref e => panic!("{} got unexpected event!: {:?}", "node_a", e),
2606-
}
2614+
};
2615+
2616+
// The payer proof is built purely from what the event handed us -- nothing is read back out
2617+
// of the payment store.
2618+
let expected_proof_note = "Paid in full".to_string();
2619+
let options = PayerProofOptions {
2620+
note: Some(expected_proof_note.clone()),
2621+
include_offer_description: true,
2622+
include_invoice_amount: true,
2623+
..Default::default()
2624+
};
2625+
let payer_proof = node_a
2626+
.bolt12_payment()
2627+
.create_payer_proof(payment_id, payment_preimage, &invoice, Some(options))
2628+
.unwrap();
2629+
assert_eq!(payer_proof.payment_preimage(), payment_preimage);
2630+
assert_eq!(payer_proof.invoice_amount_msats(), Some(expected_amount_msat));
2631+
assert_eq!(payer_proof.proof_note().map(|n| n.to_string()), Some(expected_proof_note));
2632+
assert!(payer_proof.offer_description().is_some());
2633+
// Fields we didn't ask to disclose stay absent.
2634+
assert!(payer_proof.offer_issuer().is_none());
2635+
assert!(payer_proof.invoice_created_at().is_none());
26072636
let node_a_payments =
26082637
node_a.list_payments_with_filter(|p| matches!(p.kind, PaymentKind::Bolt12Offer { .. }));
26092638
assert_eq!(node_a_payments.len(), 1);

0 commit comments

Comments
 (0)