Skip to content

Commit cd7c42d

Browse files
event: expose PaidBolt12Invoice in PaymentSuccessful for proof of payment
Add the `bolt12_invoice` field to the `PaymentSuccessful` event, enabling users to obtain proof of payment for BOLT12 transactions. Problem: 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 `bolt12_invoice: Option<PaidBolt12Invoice>` to `PaymentSuccessful`. With the UniFFI v0.29 upgrade now supporting objects in enum variants, we can expose the proper `PaidBolt12Invoice` type across both native Rust and FFI builds without cfg-gating the Event field. For non-UniFFI builds, LDK's `PaidBolt12Invoice` is re-exported directly. For UniFFI builds, a wrapper `PaidBolt12Invoice` enum is defined in ffi/types.rs with `From` conversions and delegating serialization. A minimal `StaticInvoice` FFI wrapper is also added to support the `PaidBolt12Invoice::StaticInvoice` variant. The FFI enum variants are named `Bolt12`/`Static` (rather than `Bolt12Invoice`/`StaticInvoice`) to avoid Kotlin sealed class name collisions where inner data classes would shadow top-level types. TLV tag 7 is used for serialization, maintaining backward compatibility: older readers silently skip the unknown odd tag, and newer readers deserialize `None` from events without it. Closes #757. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent a8b956a commit cd7c42d

File tree

6 files changed

+230
-4
lines changed

6 files changed

+230
-4
lines changed

src/event.rs

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,8 @@ use crate::config::{may_announce_channel, Config};
3737
use crate::connection::ConnectionManager;
3838
use crate::data_store::DataStoreUpdateResult;
3939
use crate::fee_estimator::ConfirmationTarget;
40+
#[cfg(feature = "uniffi")]
41+
use crate::ffi::PaidBolt12Invoice;
4042
use crate::io::{
4143
EVENT_QUEUE_PERSISTENCE_KEY, EVENT_QUEUE_PERSISTENCE_PRIMARY_NAMESPACE,
4244
EVENT_QUEUE_PERSISTENCE_SECONDARY_NAMESPACE,
@@ -49,6 +51,8 @@ use crate::payment::store::{
4951
PaymentDetails, PaymentDetailsUpdate, PaymentDirection, PaymentKind, PaymentStatus,
5052
};
5153
use crate::runtime::Runtime;
54+
#[cfg(not(feature = "uniffi"))]
55+
use crate::types::PaidBolt12Invoice;
5256
use crate::types::{
5357
CustomTlvRecord, DynStore, KeysManager, OnionMessenger, PaymentStore, Sweeper, Wallet,
5458
};
@@ -79,6 +83,17 @@ pub enum Event {
7983
payment_preimage: Option<PaymentPreimage>,
8084
/// The total fee which was spent at intermediate hops in this payment.
8185
fee_paid_msat: Option<u64>,
86+
/// The BOLT12 invoice that was paid.
87+
///
88+
/// This is useful for proof of payment. A third party can verify that the payment was made
89+
/// by checking that the `payment_hash` in the invoice matches `sha256(payment_preimage)`.
90+
///
91+
/// Will be `None` for non-BOLT12 payments.
92+
///
93+
/// Note that static invoices (indicated by [`PaidBolt12Invoice::StaticInvoice`], used for
94+
/// async payments) do not support proof of payment as the payment hash is not derived
95+
/// from a preimage known only to the recipient.
96+
bolt12_invoice: Option<PaidBolt12Invoice>,
8297
},
8398
/// A sent payment has failed.
8499
PaymentFailed {
@@ -268,6 +283,7 @@ impl_writeable_tlv_based_enum!(Event,
268283
(1, fee_paid_msat, option),
269284
(3, payment_id, option),
270285
(5, payment_preimage, option),
286+
(7, bolt12_invoice, option),
271287
},
272288
(1, PaymentFailed) => {
273289
(0, payment_hash, option),
@@ -1028,6 +1044,7 @@ where
10281044
payment_preimage,
10291045
payment_hash,
10301046
fee_paid_msat,
1047+
bolt12_invoice,
10311048
..
10321049
} => {
10331050
let payment_id = if let Some(id) = payment_id {
@@ -1073,6 +1090,7 @@ where
10731090
payment_hash,
10741091
payment_preimage: Some(payment_preimage),
10751092
fee_paid_msat,
1093+
bolt12_invoice: bolt12_invoice.map(Into::into),
10761094
};
10771095

10781096
match self.event_queue.add_event(event).await {

src/ffi/types.rs

Lines changed: 98 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,17 +22,20 @@ use bitcoin::hashes::Hash;
2222
use bitcoin::secp256k1::PublicKey;
2323
pub use bitcoin::{Address, BlockHash, FeeRate, Network, OutPoint, ScriptBuf, Txid};
2424
pub use lightning::chain::channelmonitor::BalanceSource;
25+
use lightning::events::PaidBolt12Invoice as LdkPaidBolt12Invoice;
2526
pub use lightning::events::{ClosureReason, PaymentFailureReason};
2627
use lightning::ln::channelmanager::PaymentId;
28+
use lightning::ln::msgs::DecodeError;
2729
pub use lightning::ln::types::ChannelId;
2830
use lightning::offers::invoice::Bolt12Invoice as LdkBolt12Invoice;
2931
pub use lightning::offers::offer::OfferId;
3032
use lightning::offers::offer::{Amount as LdkAmount, Offer as LdkOffer};
3133
use lightning::offers::refund::Refund as LdkRefund;
34+
use lightning::offers::static_invoice::StaticInvoice as LdkStaticInvoice;
3235
use lightning::onion_message::dns_resolution::HumanReadableName as LdkHumanReadableName;
3336
pub use lightning::routing::gossip::{NodeAlias, NodeId, RoutingFees};
3437
pub use lightning::routing::router::RouteParametersConfig;
35-
use lightning::util::ser::Writeable;
38+
use lightning::util::ser::{Readable, Writeable, Writer};
3639
use lightning_invoice::{Bolt11Invoice as LdkBolt11Invoice, Bolt11InvoiceDescriptionRef};
3740
pub use lightning_invoice::{Description, SignedRawBolt11Invoice};
3841
pub use lightning_liquidity::lsps0::ser::LSPSDateTime;
@@ -775,6 +778,100 @@ impl AsRef<LdkBolt12Invoice> for Bolt12Invoice {
775778
}
776779
}
777780

781+
/// A static invoice used for async payments.
782+
///
783+
/// Static invoices are a special type of BOLT12 invoice where proof of payment is not possible,
784+
/// as the payment hash is not derived from a preimage known only to the recipient.
785+
#[derive(Debug, Clone, PartialEq, Eq, uniffi::Object)]
786+
pub struct StaticInvoice {
787+
pub(crate) inner: LdkStaticInvoice,
788+
}
789+
790+
#[uniffi::export]
791+
impl StaticInvoice {
792+
/// The amount for a successful payment of the invoice, if specified.
793+
pub fn amount(&self) -> Option<OfferAmount> {
794+
self.inner.amount().map(|amount| amount.into())
795+
}
796+
}
797+
798+
impl From<LdkStaticInvoice> for StaticInvoice {
799+
fn from(invoice: LdkStaticInvoice) -> Self {
800+
StaticInvoice { inner: invoice }
801+
}
802+
}
803+
804+
impl Deref for StaticInvoice {
805+
type Target = LdkStaticInvoice;
806+
fn deref(&self) -> &Self::Target {
807+
&self.inner
808+
}
809+
}
810+
811+
impl AsRef<LdkStaticInvoice> for StaticInvoice {
812+
fn as_ref(&self) -> &LdkStaticInvoice {
813+
self.deref()
814+
}
815+
}
816+
817+
/// The BOLT12 invoice that was paid, surfaced in [`Event::PaymentSuccessful`].
818+
///
819+
/// [`Event::PaymentSuccessful`]: crate::Event::PaymentSuccessful
820+
///
821+
/// Note: The variant names intentionally differ from their contained types to avoid
822+
/// name collisions in Kotlin codegen, where sealed class inner classes would shadow
823+
/// top-level types of the same name.
824+
#[derive(Debug, Clone, PartialEq, Eq, uniffi::Enum)]
825+
pub enum PaidBolt12Invoice {
826+
/// The BOLT12 invoice, allowing the user to perform proof of payment.
827+
Bolt12(Arc<Bolt12Invoice>),
828+
/// The static invoice, used in async payments, where the user cannot perform proof of
829+
/// payment.
830+
Static(Arc<StaticInvoice>),
831+
}
832+
833+
impl From<LdkPaidBolt12Invoice> for PaidBolt12Invoice {
834+
fn from(ldk: LdkPaidBolt12Invoice) -> Self {
835+
match ldk {
836+
LdkPaidBolt12Invoice::Bolt12Invoice(invoice) => {
837+
PaidBolt12Invoice::Bolt12(Arc::new(Bolt12Invoice::from(invoice)))
838+
},
839+
LdkPaidBolt12Invoice::StaticInvoice(invoice) => {
840+
PaidBolt12Invoice::Static(Arc::new(StaticInvoice::from(invoice)))
841+
},
842+
}
843+
}
844+
}
845+
846+
impl From<PaidBolt12Invoice> for LdkPaidBolt12Invoice {
847+
fn from(wrapper: PaidBolt12Invoice) -> Self {
848+
match wrapper {
849+
PaidBolt12Invoice::Bolt12(invoice) => {
850+
LdkPaidBolt12Invoice::Bolt12Invoice(invoice.inner.clone())
851+
},
852+
PaidBolt12Invoice::Static(invoice) => {
853+
LdkPaidBolt12Invoice::StaticInvoice(invoice.inner.clone())
854+
},
855+
}
856+
}
857+
}
858+
859+
impl Writeable for PaidBolt12Invoice {
860+
fn write<W: Writer>(&self, w: &mut W) -> Result<(), lightning::io::Error> {
861+
// We clone `self` to convert it into the LDK native type for serialization.
862+
// This only runs during event persistence, so the overhead is acceptable.
863+
let ldk_type: LdkPaidBolt12Invoice = self.clone().into();
864+
ldk_type.write(w)
865+
}
866+
}
867+
868+
impl Readable for PaidBolt12Invoice {
869+
fn read<R: lightning::io::Read>(r: &mut R) -> Result<Self, DecodeError> {
870+
let ldk_type = LdkPaidBolt12Invoice::read(r)?;
871+
Ok(ldk_type.into())
872+
}
873+
}
874+
778875
uniffi::custom_type!(OfferId, String, {
779876
remote,
780877
try_lift: |val| {

src/payment/mod.rs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,3 +25,11 @@ pub use store::{
2525
ConfirmationStatus, LSPFeeLimits, PaymentDetails, PaymentDirection, PaymentKind, PaymentStatus,
2626
};
2727
pub use unified::{UnifiedPayment, UnifiedPaymentResult};
28+
29+
// We re-export `PaidBolt12Invoice` from different modules depending on the `uniffi` feature.
30+
// Under UniFFI, enum variants containing non-primitive types must be `Arc`-wrapped, so we use a
31+
// custom wrapper defined in `ffi::types`. Without UniFFI, we re-export the LDK native type directly.
32+
#[cfg(feature = "uniffi")]
33+
pub use crate::ffi::PaidBolt12Invoice;
34+
#[cfg(not(feature = "uniffi"))]
35+
pub use crate::types::PaidBolt12Invoice;

src/types.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -626,3 +626,6 @@ impl From<&(u64, Vec<u8>)> for CustomTlvRecord {
626626
}
627627

628628
pub(crate) type PendingPaymentStore = DataStore<PendingPaymentDetails, Arc<Logger>>;
629+
630+
#[cfg(not(feature = "uniffi"))]
631+
pub use lightning::events::PaidBolt12Invoice;

tests/common/mod.rs

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -953,7 +953,17 @@ pub(crate) async fn do_channel_full_cycle<E: ElectrumApi>(
953953
});
954954
assert_eq!(inbound_payments_b.len(), 1);
955955

956-
expect_event!(node_a, PaymentSuccessful);
956+
// Verify bolt12_invoice is None for BOLT11 payments
957+
match node_a.next_event_async().await {
958+
ref e @ Event::PaymentSuccessful { ref bolt12_invoice, .. } => {
959+
println!("{} got event {:?}", node_a.node_id(), e);
960+
assert!(bolt12_invoice.is_none(), "bolt12_invoice should be None for BOLT11 payments");
961+
node_a.event_handled().unwrap();
962+
},
963+
ref e => {
964+
panic!("{} got unexpected event!: {:?}", std::stringify!(node_a), e);
965+
},
966+
}
957967
expect_event!(node_b, PaymentReceived);
958968
assert_eq!(node_a.payment(&payment_id).unwrap().status, PaymentStatus::Succeeded);
959969
assert_eq!(node_a.payment(&payment_id).unwrap().direction, PaymentDirection::Outbound);

tests/integration_tests_rust.rs

Lines changed: 92 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -30,13 +30,14 @@ use ldk_node::config::{AsyncPaymentsRole, EsploraSyncConfig};
3030
use ldk_node::entropy::NodeEntropy;
3131
use ldk_node::liquidity::LSPS2ServiceConfig;
3232
use ldk_node::payment::{
33-
ConfirmationStatus, PaymentDetails, PaymentDirection, PaymentKind, PaymentStatus,
34-
UnifiedPaymentResult,
33+
ConfirmationStatus, PaidBolt12Invoice, PaymentDetails, PaymentDirection, PaymentKind,
34+
PaymentStatus, UnifiedPaymentResult,
3535
};
3636
use ldk_node::{Builder, Event, NodeError};
3737
use lightning::ln::channelmanager::PaymentId;
3838
use lightning::routing::gossip::{NodeAlias, NodeId};
3939
use lightning::routing::router::RouteParametersConfig;
40+
use lightning::util::ser::{Readable, Writeable};
4041
use lightning_invoice::{Bolt11InvoiceDescription, Description};
4142
use lightning_types::payment::{PaymentHash, PaymentPreimage};
4243
use log::LevelFilter;
@@ -1273,6 +1274,95 @@ async fn simple_bolt12_send_receive() {
12731274
assert_eq!(node_a_payments.first().unwrap().amount_msat, Some(overpaid_amount));
12741275
}
12751276

1277+
#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
1278+
async fn bolt12_proof_of_payment() {
1279+
let (bitcoind, electrsd) = setup_bitcoind_and_electrsd();
1280+
let chain_source = TestChainSource::Esplora(&electrsd);
1281+
let (node_a, node_b) = setup_two_nodes(&chain_source, false, true, false);
1282+
1283+
let address_a = node_a.onchain_payment().new_address().unwrap();
1284+
let premine_amount_sat = 5_000_000;
1285+
premine_and_distribute_funds(
1286+
&bitcoind.client,
1287+
&electrsd.client,
1288+
vec![address_a],
1289+
Amount::from_sat(premine_amount_sat),
1290+
)
1291+
.await;
1292+
1293+
node_a.sync_wallets().unwrap();
1294+
open_channel(&node_a, &node_b, 4_000_000, true, &electrsd).await;
1295+
1296+
generate_blocks_and_wait(&bitcoind.client, &electrsd.client, 6).await;
1297+
1298+
node_a.sync_wallets().unwrap();
1299+
node_b.sync_wallets().unwrap();
1300+
1301+
expect_channel_ready_event!(node_a, node_b.node_id());
1302+
expect_channel_ready_event!(node_b, node_a.node_id());
1303+
1304+
// Sleep until we broadcasted a node announcement.
1305+
while node_b.status().latest_node_announcement_broadcast_timestamp.is_none() {
1306+
tokio::time::sleep(std::time::Duration::from_millis(10)).await;
1307+
}
1308+
1309+
// Sleep one more sec to make sure the node announcement propagates.
1310+
tokio::time::sleep(std::time::Duration::from_secs(1)).await;
1311+
1312+
let expected_amount_msat = 100_000_000;
1313+
let offer = node_b
1314+
.bolt12_payment()
1315+
.receive(expected_amount_msat, "proof of payment test", None, Some(1))
1316+
.unwrap();
1317+
let payment_id =
1318+
node_a.bolt12_payment().send(&offer, Some(1), Some("Test".to_string()), None).unwrap();
1319+
1320+
// Wait for payment and verify proof of payment
1321+
let event = node_a.next_event_async().await;
1322+
match &event {
1323+
Event::PaymentSuccessful {
1324+
payment_id: event_payment_id,
1325+
payment_hash,
1326+
payment_preimage,
1327+
fee_paid_msat: _,
1328+
bolt12_invoice,
1329+
} => {
1330+
assert_eq!(*event_payment_id, Some(payment_id));
1331+
1332+
// Verify proof of payment: sha256(preimage) == payment_hash
1333+
let preimage = payment_preimage.expect("preimage should be present");
1334+
let computed_hash = Sha256Hash::hash(&preimage.0);
1335+
assert_eq!(PaymentHash(computed_hash.to_byte_array()), *payment_hash);
1336+
1337+
// Verify the BOLT12 invoice is present and contains the correct payment hash
1338+
let paid_invoice = bolt12_invoice
1339+
.as_ref()
1340+
.expect("bolt12_invoice should be present for BOLT12 payments");
1341+
match paid_invoice {
1342+
PaidBolt12Invoice::Bolt12Invoice(invoice) => {
1343+
assert_eq!(invoice.payment_hash(), *payment_hash);
1344+
assert_eq!(invoice.amount_msats(), expected_amount_msat);
1345+
},
1346+
PaidBolt12Invoice::StaticInvoice(_) => {
1347+
panic!("Expected Bolt12Invoice, got StaticInvoice");
1348+
},
1349+
}
1350+
1351+
// Verify serialization round-trip (tests TLV tag 7 backward compatibility)
1352+
let encoded = event.encode();
1353+
let decoded: Event = Readable::read(&mut std::io::Cursor::new(&encoded)).unwrap();
1354+
assert_eq!(event, decoded);
1355+
1356+
node_a.event_handled().unwrap();
1357+
},
1358+
e => {
1359+
panic!("Unexpected event: {:?}", e);
1360+
},
1361+
}
1362+
1363+
expect_payment_received_event!(node_b, expected_amount_msat);
1364+
}
1365+
12761366
#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
12771367
async fn async_payment() {
12781368
let (bitcoind, electrsd) = setup_bitcoind_and_electrsd();

0 commit comments

Comments
 (0)