From ee08962ef7c6acc281c1452d7759587092ca9c85 Mon Sep 17 00:00:00 2001 From: Vincenzo Palazzo Date: Mon, 29 Dec 2025 13:28:39 +0100 Subject: [PATCH] event: expose BOLT12 invoice in PaymentSuccessful for proof of payment This patch adds the `bolt12_invoice` field to the `PaymentSuccessful` event, enabling users to obtain proof of payment for BOLT12 transactions. Problem: Previously, after a successful BOLT12 payment, users had no way to access the paid invoice data. This made it impossible to provide proof of payment to third parties, who need both the payment preimage and the original invoice to verify that sha256(preimage) matches the invoice's payment_hash. Solution: Add a `bolt12_invoice: Option>` field to `PaymentSuccessful` that contains the serialized BOLT12 invoice bytes. The invoice is serialized using LDK's standard encoding, which can be parsed back using `Bolt12Invoice::try_from(bytes)` in native Rust, or by hex-encoding the bytes and using `Bolt12Invoice.from_str()` in FFI bindings. Design decisions: - Store as `Vec` rather than the complex `PaidBolt12Invoice` type to avoid UniFFI limitations with objects in enum variants - Return `None` for `StaticInvoice` (async payments) since proof of payment is not possible for those payment types anyway - Use TLV tag 7 for serialization, maintaining backward compatibility with existing persisted events This implementation follows the maintainer guidance from PR #563 to expose the invoice via the event rather than storing it in the payment store. Signed-off-by: Vincenzo Palazzo --- bindings/ldk_node.udl | 2 +- src/event.rs | 25 ++++++++++- tests/integration_tests_rust.rs | 78 +++++++++++++++++++++++++++++++++ 3 files changed, 103 insertions(+), 2 deletions(-) diff --git a/bindings/ldk_node.udl b/bindings/ldk_node.udl index e89158b59..b84f04658 100644 --- a/bindings/ldk_node.udl +++ b/bindings/ldk_node.udl @@ -400,7 +400,7 @@ enum VssHeaderProviderError { [Enum] interface Event { - PaymentSuccessful(PaymentId? payment_id, PaymentHash payment_hash, PaymentPreimage? payment_preimage, u64? fee_paid_msat); + PaymentSuccessful(PaymentId? payment_id, PaymentHash payment_hash, PaymentPreimage? payment_preimage, u64? fee_paid_msat, sequence? bolt12_invoice); PaymentFailed(PaymentId? payment_id, PaymentHash? payment_hash, PaymentFailureReason? reason); PaymentReceived(PaymentId? payment_id, PaymentHash payment_hash, u64 amount_msat, sequence custom_records); PaymentClaimable(PaymentId payment_id, PaymentHash payment_hash, u64 claimable_amount_msat, u32? claim_deadline, sequence custom_records); diff --git a/src/event.rs b/src/event.rs index 75270bf53..b4a7b87d1 100644 --- a/src/event.rs +++ b/src/event.rs @@ -16,7 +16,8 @@ use bitcoin::secp256k1::PublicKey; use bitcoin::{Amount, OutPoint}; use lightning::events::bump_transaction::BumpTransactionEvent; use lightning::events::{ - ClosureReason, Event as LdkEvent, PaymentFailureReason, PaymentPurpose, ReplayEvent, + ClosureReason, Event as LdkEvent, PaidBolt12Invoice, PaymentFailureReason, PaymentPurpose, + ReplayEvent, }; use lightning::impl_writeable_tlv_based_enum; use lightning::ln::channelmanager::PaymentId; @@ -75,6 +76,17 @@ pub enum Event { payment_preimage: Option, /// The total fee which was spent at intermediate hops in this payment. fee_paid_msat: Option, + /// The BOLT12 invoice that was paid, serialized as bytes. + /// + /// This is useful for proof of payment. A third party can verify that the payment was made + /// by checking that the `payment_hash` in the invoice matches `sha256(payment_preimage)`. + /// + /// Will be `None` for non-BOLT12 payments, or for async payments (`StaticInvoice`) + /// where proof of payment is not possible. + /// + /// To parse the invoice in native Rust, use `Bolt12Invoice::try_from(bytes)`. + /// In FFI bindings, hex-encode the bytes and use `Bolt12Invoice.from_str(hex_string)`. + bolt12_invoice: Option>, }, /// A sent payment has failed. PaymentFailed { @@ -264,6 +276,7 @@ impl_writeable_tlv_based_enum!(Event, (1, fee_paid_msat, option), (3, payment_id, option), (5, payment_preimage, option), + (7, bolt12_invoice, option), }, (1, PaymentFailed) => { (0, payment_hash, option), @@ -1022,6 +1035,7 @@ where payment_preimage, payment_hash, fee_paid_msat, + bolt12_invoice, .. } => { let payment_id = if let Some(id) = payment_id { @@ -1062,11 +1076,20 @@ where hex_utils::to_string(&payment_preimage.0) ); }); + + // Serialize the BOLT12 invoice to bytes for proof of payment. + // Only Bolt12Invoice supports proof of payment; StaticInvoice does not. + let bolt12_invoice_bytes = bolt12_invoice.and_then(|inv| match inv { + PaidBolt12Invoice::Bolt12Invoice(invoice) => Some(invoice.encode()), + PaidBolt12Invoice::StaticInvoice(_) => None, + }); + let event = Event::PaymentSuccessful { payment_id: Some(payment_id), payment_hash, payment_preimage: Some(payment_preimage), fee_paid_msat, + bolt12_invoice: bolt12_invoice_bytes, }; match self.event_queue.add_event(event).await { diff --git a/tests/integration_tests_rust.rs b/tests/integration_tests_rust.rs index 9b02cd61f..424e38925 100644 --- a/tests/integration_tests_rust.rs +++ b/tests/integration_tests_rust.rs @@ -33,6 +33,7 @@ use ldk_node::payment::{ }; use ldk_node::{Builder, Event, NodeError}; use lightning::ln::channelmanager::PaymentId; +use lightning::offers::invoice::Bolt12Invoice as LdkBolt12Invoice; use lightning::routing::gossip::{NodeAlias, NodeId}; use lightning::routing::router::RouteParametersConfig; use lightning_invoice::{Bolt11InvoiceDescription, Description}; @@ -1303,6 +1304,83 @@ async fn simple_bolt12_send_receive() { assert_eq!(node_a_payments.first().unwrap().amount_msat, Some(overpaid_amount)); } +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +async fn bolt12_proof_of_payment() { + let (bitcoind, electrsd) = setup_bitcoind_and_electrsd(); + let chain_source = TestChainSource::Esplora(&electrsd); + let (node_a, node_b) = setup_two_nodes(&chain_source, false, true, false); + + let address_a = node_a.onchain_payment().new_address().unwrap(); + let premine_amount_sat = 5_000_000; + premine_and_distribute_funds( + &bitcoind.client, + &electrsd.client, + vec![address_a], + Amount::from_sat(premine_amount_sat), + ) + .await; + + node_a.sync_wallets().unwrap(); + open_channel(&node_a, &node_b, 4_000_000, true, &electrsd).await; + + generate_blocks_and_wait(&bitcoind.client, &electrsd.client, 6).await; + + node_a.sync_wallets().unwrap(); + node_b.sync_wallets().unwrap(); + + expect_channel_ready_event!(node_a, node_b.node_id()); + expect_channel_ready_event!(node_b, node_a.node_id()); + + // Sleep until we broadcasted a node announcement. + while node_b.status().latest_node_announcement_broadcast_timestamp.is_none() { + tokio::time::sleep(std::time::Duration::from_millis(10)).await; + } + + // Sleep one more sec to make sure the node announcement propagates. + tokio::time::sleep(std::time::Duration::from_secs(1)).await; + + let expected_amount_msat = 100_000_000; + let offer = node_b + .bolt12_payment() + .receive(expected_amount_msat, "proof of payment test", None, Some(1)) + .unwrap(); + let payment_id = + node_a.bolt12_payment().send(&offer, Some(1), Some("Test".to_string()), None).unwrap(); + + // Wait for payment and verify proof of payment + match node_a.next_event_async().await { + Event::PaymentSuccessful { + payment_id: event_payment_id, + payment_hash, + payment_preimage, + fee_paid_msat: _, + bolt12_invoice, + } => { + assert_eq!(event_payment_id, Some(payment_id)); + + // Verify proof of payment: sha256(preimage) == payment_hash + let preimage = payment_preimage.expect("preimage should be present"); + let computed_hash = Sha256Hash::hash(&preimage.0); + assert_eq!(PaymentHash(computed_hash.to_byte_array()), payment_hash); + + // Verify the BOLT12 invoice is present and contains the correct payment hash + let invoice_bytes = + bolt12_invoice.expect("bolt12_invoice should be present for BOLT12 payments"); + let invoice = LdkBolt12Invoice::try_from(invoice_bytes) + .expect("should be able to parse invoice from bytes"); + assert_eq!(invoice.payment_hash(), payment_hash); + assert_eq!(invoice.amount_msats(), expected_amount_msat); + + node_a.event_handled().unwrap(); + }, + ref e => { + panic!("Unexpected event: {:?}", e); + }, + } + + expect_payment_received_event!(node_b, expected_amount_msat); +} + #[tokio::test(flavor = "multi_thread", worker_threads = 1)] async fn async_payment() { let (bitcoind, electrsd) = setup_bitcoind_and_electrsd();