Skip to content

Conversation

@vincenzopalazzo
Copy link
Contributor

This is a first draft implementation of the payer proof extension to BOLT 12 as proposed in lightning/bolts#1295. The goal is to get early feedback on the API design before the spec is finalized.

Payer proofs allow proving that a BOLT 12 invoice was paid by demonstrating possession of:

  • The payment preimage
  • A valid invoice signature over a merkle root
  • The payer's signature

This PR adds the core building blocks:

  • Extends merkle.rs with selective disclosure primitives that allow creating and reconstructing merkle trees with partial TLV disclosure. This enables proving invoice authenticity while omitting sensitive fields.
  • Adds payer_proof.rs with PayerProof, PayerProofBuilder, and UnsignedPayerProof types. The builder pattern allows callers to selectively include invoice fields (description, amount, etc.) in the proof.
  • Implements bech32 encoding/decoding with the lnp prefix and proper TLV stream parsing with validation (ascending order, no duplicates, hash length checks).

This is explicitly a PoC to validate the API surface - the spec itself is still being refined. Looking for feedback on:

  • Whether the builder pattern makes sense for selective disclosure
  • The verification API
  • Integration points with the rest of the offers module

cc @TheBlueMatt @jkczyz

@ldk-reviews-bot
Copy link

ldk-reviews-bot commented Jan 5, 2026

I've assigned @valentinewallace as a reviewer!
I'll wait for their review and will help manage the review process.
Once they submit their review, I'll check if a second reviewer would be helpful.

@codecov
Copy link

codecov bot commented Jan 5, 2026

Codecov Report

❌ Patch coverage is 59.23633% with 395 lines in your changes missing coverage. Please review.
✅ Project coverage is 86.36%. Comparing base (09b3bef) to head (9f84e19).
⚠️ Report is 9 commits behind head on main.

Files with missing lines Patch % Lines
lightning/src/offers/payer_proof.rs 33.27% 355 Missing and 10 partials ⚠️
lightning/src/offers/merkle.rs 92.89% 25 Missing and 5 partials ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main    #4297      +/-   ##
==========================================
- Coverage   86.61%   86.36%   -0.26%     
==========================================
  Files         158      159       +1     
  Lines      102730   103699     +969     
  Branches   102730   103699     +969     
==========================================
+ Hits        88984    89556     +572     
- Misses      11328    11708     +380     
- Partials     2418     2435      +17     
Flag Coverage Δ
fuzzing 36.78% <0.00%> (+0.57%) ⬆️
tests 85.66% <59.23%> (-0.26%) ⬇️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

Copy link
Collaborator

@TheBlueMatt TheBlueMatt left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A few notes, though I didn't dig into the code at a particularly low level.

const TLV_INVREQ_PAYER_ID: u64 = 88;
const TLV_INVOICE_PAYMENT_HASH: u64 = 168;
const TLV_INVOICE_FEATURES: u64 = 174;
const TLV_INVOICE_NODE_ID: u64 = 176;
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: most of these constants can/should be reused elsewhere in the code

Copy link
Contributor Author

@vincenzopalazzo vincenzopalazzo Jan 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in b16dc60 - now importing and using INVOICE_REQUEST_PAYER_ID_TYPE from invoice_request.rs. Also added a TODO for the invoice TLV types (168, 174, 176) which could potentially be exported from invoice.rs.

struct PayerProofContents {
payer_id: PublicKey,
payment_hash: PaymentHash,
invoice_node_id: PublicKey,
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I believe we refer to this as the "issuer signing pubkey" elsewhere to avoid overloading with the "node id" (the p2p thing) cause we (at a minimum, I guess CLN does not) try hard to avoid using the node id again in bolt 12.

Copy link
Contributor Author

@vincenzopalazzo vincenzopalazzo Jan 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in e609ea5 - renamed invoice_node_id to issuer_signing_pubkey throughout (field, constant, accessor method, and doc comments).

self.included_types.insert(164);
self
}

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Probably worth having a generic "select by id" thing (maybe requiring it be in the experimental range?), given we'll add support for custom TLVs eventually.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There's already an include_type(tlv_type: u64) method that allows including any TLV by its type number (except invreq_metadata which is forbidden by spec). Is that what you're looking for, or did you want something more restrictive that only allows experimental types (>= 1,000,000,000)?

}

/// Sign the proof with the payer's key to create a complete proof.
pub fn sign<F>(self, sign_fn: F, note: Option<&str>) -> Result<PayerProof, PayerProofError>
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should also have a signing method which uses the automated payer key derivation from an ExpandedKey.

Copy link
Contributor Author

@vincenzopalazzo vincenzopalazzo Jan 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added in 7b523bf - new sign_with_derived_key(expanded_key, nonce, note) method that derives the payer signing key using derive_keys() and signs the proof. It also validates that the derived key matches the expected payer_id.

break;
}

let left_positions: Vec<_> = (0..num_leaves).step_by(step).collect();
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ha, please don't collect to build a vec.

Copy link
Contributor Author

@vincenzopalazzo vincenzopalazzo Jan 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 9f84e19 - now using direct iterator zipping like root_hash() does:

for (left_pos, right_pos) in (0..num_leaves).step_by(step).zip((offset..num_leaves).step_by(step)) {

#[derive(Clone, Debug, PartialEq)]
pub struct SelectiveDisclosure {
/// Nonce hashes for included TLVs (in TLV type order).
pub leaf_hashes: Vec<sha256::Hash>,
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm confused, don't we need the tlv id in this? This doesn't look sufficient.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The current implementation stores nonce hashes without TLV type IDs. The types come from included_records passed separately to reconstruct_merkle_root(), and we match by position (both in ascending TLV type order).

I think what might "not look sufficient" is that SelectiveDisclosure isn't self-contained - you need the accompanying included_records to know which TLV type each hash belongs to. Looking at the struct alone, there's no way to know what each hash corresponds to.

Would you prefer making this explicit by storing (tlv_type, hash) pairs instead?

pub nonce_hashes: Vec<(u64, sha256::Hash)>,  // (tlv_type, nonce_hash) pairs

This would make the struct self-describing. Let me know your thoughts.

Comment on lines +428 to +429
let mut is_included: Vec<bool> = vec![false; num_leaves];
let mut min_types: Vec<u64> = vec![u64::MAX; num_leaves];
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would be nice to avoid the unnecessary allocations here.

let branch_tag = tagged_hash_engine(sha256::Hash::hash("LnBranch".as_bytes()));

let mut hashes: Vec<Option<sha256::Hash>> = vec![None; num_leaves];
let mut is_included: Vec<bool> = vec![false; num_leaves];
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same here, there's lot of allocations that we should be able to avoid.

#[allow(dead_code)]
leaf_hashes: Vec<sha256::Hash>,
#[allow(dead_code)]
omitted_tlvs: Vec<u64>,
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This shouldn't be revealed, only the presence of N TLV(s) between X (the included TLV below) and Y (the included TLV above) should be required, no? Its a bit of a privacy leak to reveal the set of TLVs in some cases, I can imagine.

Copy link
Contributor Author

@vincenzopalazzo vincenzopalazzo Jan 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The implementation already does this if I understand your comment correctly - the field stores privacy-preserving markers, not actual TLV type numbers. Looking at the spec example:

  • TLVs: 0(omit), 10(incl), 20(omit), 30(omit), 40(incl), 50(omit), 60(omit)
  • Markers stored: [11, 12, 41, 42]

The marker algorithm (in compute_omitted_markers):

  1. After included TLV type X, the first omitted TLV gets marker X+1
  2. Consecutive omitted TLVs get marker prev_marker+1

So markers [11, 12] only reveal "2 omitted TLVs after type 10" and [41, 42] reveals "2 omitted TLVs after type 40" - it does not reveal that types 20, 30, 50, 60 were the actual omitted types.

The naming omitted_tlvs is confusing though - I can rename it to omitted_markers to make it clearer that these aren't actual TLV types. Does that address your concern, or did I misunderstand?

invoice_created_at: Option<Duration>,
#[allow(dead_code)]
invoice_features: Option<Bolt12InvoiceFeatures>,
}
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We presumably want a place to store custom included TLVs.

@vincenzopalazzo vincenzopalazzo marked this pull request as ready for review January 20, 2026 17:00
@vincenzopalazzo vincenzopalazzo force-pushed the macros/proof-of-payment-bolt12-spec branch from 84597cc to 2324361 Compare January 20, 2026 17:25
Implements the payer proof extension to BOLT 12 as specified in
lightning/bolts#1295. This allows proving
that a BOLT 12 invoice was paid by demonstrating possession of the
payment preimage, a valid invoice signature, and a payer signature.

Key additions:
- Extend merkle.rs with selective disclosure primitives for creating
  and reconstructing merkle trees with partial TLV disclosure
- Add payer_proof.rs with PayerProof, PayerProofBuilder, and
  UnsignedPayerProof types for building and verifying payer proofs
- Support bech32 encoding with "lnp" prefix
@vincenzopalazzo vincenzopalazzo force-pushed the macros/proof-of-payment-bolt12-spec branch from 2324361 to 9f84e19 Compare January 20, 2026 17:42
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants