Skip to content

Commit fbb3a7c

Browse files
Add BOLT 12 payer proof support
Expose `Bolt12Payment::create_payer_proof`, which builds a BOLT 12 payer proof for a previously succeeded outbound BOLT 12 payment, with `PayerProofOptions` controlling which optional invoice fields are selectively disclosed. The proof is built purely from data we already persist in the payment store: the paid BOLT 12 invoice recorded on `PaymentKind::Bolt12Offer` / `PaymentKind::Bolt12Refund` and the payment preimage. That means payer proofs survive restarts and we don't need a second, node-lifetime-only store to keep the invoice context around. Payments that completed via a static invoice, i.e., async payments, don't support payer proofs and are rejected with `PayerProofUnavailable`. Also wires the new `PayerProof` type and the two new error variants through the UniFFI surface. This commit was written with AI assistance. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent f812d84 commit fbb3a7c

7 files changed

Lines changed: 272 additions & 5 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+
- The paid BOLT 12 invoice is now persisted on `PaymentKind::Bolt12Offer` and
36+
`PaymentKind::Bolt12Refund`, and `Bolt12Payment::create_payer_proof` allows building a BOLT 12
37+
payer proof for a previously succeeded outbound BOLT 12 payment. `PayerProofOptions` controls
38+
which optional invoice fields are selectively disclosed. Payments that completed via a static
39+
invoice, i.e., async payments, do not support payer proofs. (#845)
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: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -206,6 +206,8 @@ enum NodeError {
206206
"FeerateEstimationUpdateTimeout",
207207
"WalletOperationFailed",
208208
"WalletOperationTimeout",
209+
"PayerProofCreationFailed",
210+
"PayerProofUnavailable",
209211
"OnchainTxSigningFailed",
210212
"TxSyncFailed",
211213
"TxSyncTimeout",
@@ -247,6 +249,7 @@ enum NodeError {
247249
"LnurlAuthTimeout",
248250
"InvalidLnurl",
249251
"ChainSourceNotSupported",
252+
"InvalidPayerProof",
250253
};
251254

252255
typedef dictionary NodeStatus;

src/error.rs

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,10 @@ pub enum Error {
5757
WalletOperationFailed,
5858
/// A wallet operation timed out.
5959
WalletOperationTimeout,
60+
/// Creating a payer proof failed.
61+
PayerProofCreationFailed,
62+
/// A payer proof is unavailable for the requested payment.
63+
PayerProofUnavailable,
6064
/// A signing operation for transaction failed.
6165
OnchainTxSigningFailed,
6266
/// A transaction sync operation failed.
@@ -139,6 +143,8 @@ pub enum Error {
139143
InvalidLnurl,
140144
/// The configured chain source is not supported.
141145
ChainSourceNotSupported,
146+
/// The provided payer proof is invalid.
147+
InvalidPayerProof,
142148
}
143149

144150
impl fmt::Display for Error {
@@ -170,6 +176,10 @@ impl fmt::Display for Error {
170176
},
171177
Self::WalletOperationFailed => write!(f, "Failed to conduct wallet operation."),
172178
Self::WalletOperationTimeout => write!(f, "A wallet operation timed out."),
179+
Self::PayerProofCreationFailed => write!(f, "Failed to create payer proof."),
180+
Self::PayerProofUnavailable => {
181+
write!(f, "A payer proof is unavailable for the requested payment.")
182+
},
173183
Self::OnchainTxSigningFailed => write!(f, "Failed to sign given transaction."),
174184
Self::TxSyncFailed => write!(f, "Failed to sync transactions."),
175185
Self::TxSyncTimeout => write!(f, "Syncing transactions timed out."),
@@ -227,6 +237,7 @@ impl fmt::Display for Error {
227237
Self::ChainSourceNotSupported => {
228238
write!(f, "The configured chain source is not supported.")
229239
},
240+
Self::InvalidPayerProof => write!(f, "The provided payer proof is invalid."),
230241
}
231242
}
232243
}

src/ffi/types.rs

Lines changed: 110 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,113 @@ impl Readable for PaidBolt12Invoice {
881883
}
882884
}
883885

886+
/// A cryptographic proof that a BOLT12 invoice was paid by this node.
887+
#[derive(Debug, Clone, uniffi::Object)]
888+
#[uniffi::export(Debug, Display)]
889+
pub struct PayerProof {
890+
pub(crate) inner: LdkPayerProof,
891+
}
892+
893+
#[uniffi::export]
894+
impl PayerProof {
895+
#[uniffi::constructor]
896+
pub fn from_bytes(proof_bytes: Vec<u8>) -> Result<Self, Error> {
897+
let inner = LdkPayerProof::try_from(proof_bytes).map_err(|_| Error::InvalidPayerProof)?;
898+
Ok(Self { inner })
899+
}
900+
901+
/// The payment preimage proving the payment completed.
902+
pub fn payment_preimage(&self) -> PaymentPreimage {
903+
self.inner.payment_preimage()
904+
}
905+
906+
/// The payment hash committed to by the invoice and proven by the preimage.
907+
pub fn payment_hash(&self) -> PaymentHash {
908+
self.inner.payment_hash()
909+
}
910+
911+
/// The public key of the payer that authorized the payment.
912+
pub fn payer_signing_pubkey(&self) -> PublicKey {
913+
self.inner.payer_signing_pubkey()
914+
}
915+
916+
/// The issuer signing public key committed to by the invoice.
917+
pub fn issuer_signing_pubkey(&self) -> PublicKey {
918+
self.inner.issuer_signing_pubkey()
919+
}
920+
921+
/// The invoice signature bytes.
922+
pub fn invoice_signature(&self) -> Vec<u8> {
923+
self.inner.invoice_signature().as_ref().to_vec()
924+
}
925+
926+
/// The proof signature bytes.
927+
pub fn proof_signature(&self) -> Vec<u8> {
928+
self.inner.proof_signature().as_ref().to_vec()
929+
}
930+
931+
/// The offer description, if it was disclosed in the proof.
932+
pub fn offer_description(&self) -> Option<String> {
933+
self.inner.offer_description().map(|value| value.to_string())
934+
}
935+
936+
/// The offer issuer, if it was disclosed in the proof.
937+
pub fn offer_issuer(&self) -> Option<String> {
938+
self.inner.offer_issuer().map(|value| value.to_string())
939+
}
940+
941+
/// The invoice amount in millisatoshis, if it was disclosed in the proof.
942+
pub fn invoice_amount_msats(&self) -> Option<u64> {
943+
self.inner.invoice_amount_msats()
944+
}
945+
946+
/// The invoice creation time, in seconds since the UNIX epoch, if it was disclosed in the
947+
/// proof.
948+
pub fn invoice_created_at(&self) -> Option<u64> {
949+
self.inner.invoice_created_at().map(|value| value.as_secs())
950+
}
951+
952+
/// The optional note attached to the proof.
953+
pub fn proof_note(&self) -> Option<String> {
954+
self.inner.proof_note().map(|value| value.to_string())
955+
}
956+
957+
/// The Merkle root committed to by the proof.
958+
pub fn merkle_root(&self) -> Vec<u8> {
959+
self.inner.merkle_root().to_byte_array().to_vec()
960+
}
961+
962+
/// The raw TLV bytes of the proof.
963+
pub fn bytes(&self) -> Vec<u8> {
964+
self.inner.bytes().to_vec()
965+
}
966+
967+
/// The bech32-encoded string form of the proof.
968+
pub fn as_string(&self) -> String {
969+
self.inner.to_string()
970+
}
971+
}
972+
973+
impl From<LdkPayerProof> for PayerProof {
974+
fn from(inner: LdkPayerProof) -> Self {
975+
Self { inner }
976+
}
977+
}
978+
979+
impl Deref for PayerProof {
980+
type Target = LdkPayerProof;
981+
982+
fn deref(&self) -> &Self::Target {
983+
&self.inner
984+
}
985+
}
986+
987+
impl std::fmt::Display for PayerProof {
988+
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
989+
write!(f, "{}", self.inner)
990+
}
991+
}
992+
884993
uniffi::custom_type!(OfferId, String, {
885994
remote,
886995
try_lift: |val| {

src/payment/bolt12.rs

Lines changed: 119 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,11 @@ 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+
5564
/// A payment handler allowing to create and pay [BOLT 12] offers and refunds.
5665
///
5766
/// Should be retrieved by calling [`Node::bolt12_payment`].
@@ -70,6 +79,24 @@ pub struct Bolt12Payment {
7079
async_payments_role: Option<AsyncPaymentsRole>,
7180
}
7281

82+
/// Options controlling which optional fields are disclosed in a BOLT12 payer proof.
83+
#[derive(Clone, Debug, PartialEq, Eq, Default)]
84+
#[cfg_attr(feature = "uniffi", derive(uniffi::Record))]
85+
pub struct PayerProofOptions {
86+
/// An optional note attached to the payer proof itself.
87+
pub note: Option<String>,
88+
/// Whether to include the offer description in the proof.
89+
pub include_offer_description: bool,
90+
/// Whether to include the offer issuer in the proof.
91+
pub include_offer_issuer: bool,
92+
/// Whether to include the invoice amount in the proof.
93+
pub include_invoice_amount: bool,
94+
/// Whether to include the invoice creation timestamp in the proof.
95+
pub include_invoice_created_at: bool,
96+
/// Additional TLV types to include in the selective disclosure set.
97+
pub extra_tlv_types: Vec<u64>,
98+
}
99+
73100
impl Bolt12Payment {
74101
pub(crate) fn new(
75102
runtime: Arc<Runtime>, channel_manager: Arc<ChannelManager>,
@@ -253,6 +280,33 @@ impl Bolt12Payment {
253280
.blinded_paths_for_async_recipient(recipient_id, None)
254281
.or(Err(Error::InvalidBlindedPaths))
255282
}
283+
284+
/// Retrieves the persisted payer proof context, i.e., the paid BOLT 12 invoice and the payment
285+
/// preimage, for a successful outbound BOLT 12 payment.
286+
fn payer_proof_context(
287+
&self, payment_id: &PaymentId,
288+
) -> Result<(LdkPaidBolt12Invoice, PaymentPreimage), Error> {
289+
let payment = self.payment_store.get(payment_id).ok_or(Error::PayerProofUnavailable)?;
290+
if payment.direction != PaymentDirection::Outbound
291+
|| payment.status != PaymentStatus::Succeeded
292+
{
293+
return Err(Error::PayerProofUnavailable);
294+
}
295+
296+
match payment.kind {
297+
PaymentKind::Bolt12Offer {
298+
preimage: Some(preimage),
299+
bolt12_invoice: Some(invoice),
300+
..
301+
}
302+
| PaymentKind::Bolt12Refund {
303+
preimage: Some(preimage),
304+
bolt12_invoice: Some(invoice),
305+
..
306+
} => Ok((invoice.into(), preimage)),
307+
_ => Err(Error::PayerProofUnavailable),
308+
}
309+
}
256310
}
257311

258312
#[cfg_attr(feature = "uniffi", uniffi::export)]
@@ -393,6 +447,70 @@ impl Bolt12Payment {
393447
Ok(payment_id)
394448
}
395449

450+
/// Create a payer proof for a previously succeeded outbound BOLT 12 payment.
451+
///
452+
/// This requires a standard BOLT 12 invoice response, which we persist alongside the payment
453+
/// in the payment store. Payments that completed via a static invoice, i.e., async payments,
454+
/// do not support payer proofs.
455+
pub fn create_payer_proof(
456+
&self, payment_id: &PaymentId, options: Option<PayerProofOptions>,
457+
) -> Result<PayerProof, Error> {
458+
let (paid_invoice, preimage) = self.payer_proof_context(payment_id)?;
459+
460+
let options = options.unwrap_or_default();
461+
let expanded_key = self.keys_manager.get_expanded_key();
462+
let secp_ctx = bitcoin::secp256k1::Secp256k1::new();
463+
464+
let mut builder = paid_invoice
465+
.prove_payer_derived(preimage, &expanded_key, *payment_id, &secp_ctx)
466+
.map_err(|e| {
467+
log_error!(
468+
self.logger,
469+
"Failed to initialize payer proof builder for {}: {:?}",
470+
payment_id,
471+
e
472+
);
473+
Error::PayerProofCreationFailed
474+
})?;
475+
476+
for tlv_type in options.extra_tlv_types {
477+
builder = builder.include_type(tlv_type).map_err(|e| {
478+
log_error!(
479+
self.logger,
480+
"Failed to include TLV {} in payer proof for {}: {:?}",
481+
tlv_type,
482+
payment_id,
483+
e
484+
);
485+
Error::PayerProofCreationFailed
486+
})?;
487+
}
488+
489+
if options.include_offer_description {
490+
builder = builder.include_offer_description();
491+
}
492+
if options.include_offer_issuer {
493+
builder = builder.include_offer_issuer();
494+
}
495+
if options.include_invoice_amount {
496+
builder = builder.include_invoice_amount();
497+
}
498+
if options.include_invoice_created_at {
499+
builder = builder.include_invoice_created_at();
500+
}
501+
502+
if let Some(note) = options.note {
503+
builder = builder.with_proof_note(note);
504+
}
505+
506+
let proof = builder.build_and_sign().map_err(|e| {
507+
log_error!(self.logger, "Failed to build payer proof for {}: {:?}", payment_id, e);
508+
Error::PayerProofCreationFailed
509+
})?;
510+
511+
Ok(maybe_wrap(proof))
512+
}
513+
396514
/// Returns a payable offer that can be used to request and receive a payment of the amount
397515
/// given.
398516
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;

0 commit comments

Comments
 (0)