Skip to content

offer: make the merkle tree signature public #3892

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 2 commits into
base: main
Choose a base branch
from

Conversation

vincenzopalazzo
Copy link
Contributor

This is helpful for users who want to use the Merkle tree signature in their own code, for example, to verify the signature of bolt12 invoices or recreate it.

Very useful for people who are building command line tools for the bolt12 offers.

I am opening this, but I do not know if it is something that you want to do, but at the same time, IMHO, this is very useful to expose because it allows to use LDK in command line tools and in learning tools.

However, this is not strictly necessary because Bolt12Invoice::try_from already verifies the signature, but I would open this to know your point of view.

@ldk-reviews-bot
Copy link

ldk-reviews-bot commented Jun 26, 2025

👋 I see @TheBlueMatt was un-assigned.
If you'd like another reviewer assignment, please click here.

@dunxen
Copy link
Contributor

dunxen commented Jun 26, 2025

I'm not personally opposed to this and it may be nice to expose it for the use cases you mention.
However, maybe the doc comments for these should be updated to indicate that these don't need to be used directly and they're really for internal use, but made public for use in applications like you specified. We should redirect them to the appropriate methods for production use.

I'm also not sure about any API guarantees for these methods. I don't think it's too much of a hassle to treat them the same way we treat any other public API.

Copy link

codecov bot commented Jun 26, 2025

Codecov Report

All modified and coverable lines are covered by tests ✅

Project coverage is 88.82%. Comparing base (0fe51c5) to head (699f37f).
Report is 57 commits behind head on main.

Additional details and impacted files
@@            Coverage Diff             @@
##             main    #3892      +/-   ##
==========================================
- Coverage   89.66%   88.82%   -0.85%     
==========================================
  Files         164      166       +2     
  Lines      134661   119309   -15352     
  Branches   134661   119309   -15352     
==========================================
- Hits       120743   105972   -14771     
+ Misses      11237    11011     -226     
+ Partials     2681     2326     -355     

☔ 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.

@@ -41,7 +41,7 @@ impl TaggedHash {
/// Creates a tagged hash with the given parameters.
///
/// Panics if `bytes` is not a well-formed TLV stream containing at least one TLV record.
pub(super) fn from_valid_tlv_stream_bytes(tag: &'static str, bytes: &[u8]) -> Self {
pub fn from_valid_tlv_stream_bytes(tag: &'static str, bytes: &[u8]) -> Self {
Copy link
Contributor

Choose a reason for hiding this comment

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

Do we have a user for this? Feels pretty low-level to be exposing. Echo dunxen's sentiments about improving the docs if we do want to expose it.

Copy link
Collaborator

Choose a reason for hiding this comment

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

Yea, if we're gonna expose this we should really add a validating wrapper version that checks the stream is valid and can Err instead of panicking.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Do we have a user for this? Feels pretty low-level to be exposing.

Yeah, there is. Everyone who wants to verify that an offer is linked to a bolt12 invoice, also if the big picture is that the bolt12 invoice will never be exposed, but for now that we do not have a better PoP a person should allow this kind of verification IMHO

A possible example of verification can be

/// Verify that an offer, invoice, and preimage are valid and consistent
pub fn verify_offer_payment(offer: &str, invoice: &str, preimage: &str) -> Result<()> {
    log::info!("Starting verification process...");
    log::info!("Offer: {}", offer);
    log::info!("Invoice: {}", invoice);
    log::info!("Preimage: {}", preimage);

    let offer =
        Offer::from_str(offer).map_err(|e| anyhow::anyhow!("Failed to parse offer: {:?}", e))?;

    // Parse the bolt12 invoice preimage
    let (hrp, data) = bech32::decode_without_checksum(invoice)?;
    if hrp.as_str() != "lni" {
        return Err(anyhow::anyhow!(
            "Invalid HRP: expected 'lni', found '{}'",
            hrp
        ));
    }

    let data = Vec::<u8>::from_base32(&data)?;

    let invoice = Bolt12Invoice::try_from(data)
        .map_err(|e| anyhow::anyhow!("Failed to parse BOLT12: {e:?}"))?;

    let issuer_sign_pubkey = if let Some(issue_sign_pubkey) = offer.issuer_signing_pubkey() {
        issue_sign_pubkey
    } else {
        let valid_signing_keys = offer
            .paths()
            .iter()
            .filter_map(|path| path.blinded_hops().last())
            .map(|hop| hop.blinded_node_id)
            .collect::<Vec<_>>();
        log::debug!("{:?}", valid_signing_keys);
        let valid_signing_keys = valid_signing_keys
            .first()
            .ok_or(anyhow::anyhow!(
                "Offer does not contain issuer signing pubkey"
            ))?
            .clone();
        valid_signing_keys
    };

    let inv_signing_pubkey = invoice.signing_pubkey();
    if issuer_sign_pubkey != inv_signing_pubkey {
        return Err(anyhow::anyhow!(
            "Offer and invoice issuer signing pubkeys do not match"
        ));
    }

    let mut bytes = vec![];
    invoice
        .write(&mut bytes)
        .map_err(|e| anyhow::anyhow!("Failed to serialize invoice: {:?}", e))?;
    let tagged_hash = TaggedHash::from_valid_tlv_stream_bytes(SIGNATURE_TAG, &bytes);
    if let Err(err) =
        merkle::verify_signature(&invoice.signature(), &tagged_hash, issuer_sign_pubkey)
    {
        anyhow::bail!("Failed to verify invoice signature: {:?}", err);
    }

    let preimage =
        hex::decode(preimage).map_err(|e| anyhow::anyhow!("Failed to decode preimage: {:?}", e))?;
    let preimage: [u8; 32] = preimage
        .try_into()
        .map_err(|_| anyhow::anyhow!("Preimage must be 32 bytes"))?;
    let preimage = PaymentPreimage(preimage);
    // Validate the Proof of Payment for the invoice
    let computed_hash = PaymentHash::from(preimage);
    let payment_hash = invoice.payment_hash();

    if computed_hash != payment_hash {
        anyhow::bail!("Fail to perform PoP");
    }

    log::info!("Parsed offer: {:?}", offer);
    log::info!("Parsed invoice: {:?}", invoice);
    Ok(())
}

Copy link
Contributor

Choose a reason for hiding this comment

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

Hmm... we verify the invoice's signature when parsing it. So it's just a matter of matching that the Offer given is the same one that is part of the Bolt12Invoice. This should be possible by comparing Offer::id -- which is just the tagged hash of the offer's merkle root -- against one obtained from the Bolt12Invoice. We could possibly add Bolt12Invoice::offer_id for this.

Note that while OfferId uses an LDK-specific tag, it is computed over the TLV stream bytes so it doesn't matter if the offer wasn't created with LDK.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Ok implemented it @jkczyz cfd6ad4 let me know what do you think.

However, I still think that ldk should expose a way to verify the signature with the public key that is inside the offer, at least till we do not get a better PoP with bolt12

Copy link
Contributor

Choose a reason for hiding this comment

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

Ok implemented it @jkczyz cfd6ad4 let me know what do you think.

Thanks, left some comments.

However, I still think that ldk should expose a way to verify the signature with the public key that is inside the offer, at least till we do not get a better PoP with bolt12

Given the signature is verified when parsing, shouldn't it be sufficient to check that Bolt12Invoice::signing_pubkey matches the one in the offer? The parser is already doing this when calling check_invoice_signing_pubkey, too, FWIW. Not sure I understand why we need to expose these for the user to piece together and call on their own.

At very least, there doesn't seem to be a need to expose TaggedHash::from_valid_tlv_stream_bytes if we can just have a method on Bolt12Invoice give the TaggedHash for manual signature verification.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Given the signature is verified when parsing, shouldn't it be sufficient to check that Bolt12Invoice::signing_pubkey matches the one in the offer?

Yes, good point, I think it is just fine for a proof of linking point of view.

FWIW. Not sure I understand why we need to expose these for the user to piece together and call on their own.

At this point is just for a learning tool purpose if some users want to do the versification as specified inside the spec. Probably, this is a separate discussion

At very least, there doesn't seem to be a need to expose TaggedHash::from_valid_tlv_stream_bytes if we can just have a method on Bolt12Invoice give the TaggedHash for manual signature verification.

This is a really good point, lets me experiment with it

@ldk-reviews-bot
Copy link

👋 The first review has been submitted!

Do you think this PR is ready for a second reviewer? If so, click here to assign a second reviewer.

@ldk-reviews-bot
Copy link

🔔 1st Reminder

Hey @TheBlueMatt @valentinewallace! This PR has been waiting for your review.
Please take a look when you have a chance. If you're unable to review, please let us know so we can find another reviewer.

1 similar comment
@ldk-reviews-bot
Copy link

🔔 1st Reminder

Hey @TheBlueMatt @valentinewallace! This PR has been waiting for your review.
Please take a look when you have a chance. If you're unable to review, please let us know so we can find another reviewer.

@vincenzopalazzo
Copy link
Contributor Author

cc @jkczyz

@vincenzopalazzo vincenzopalazzo force-pushed the macros/markle-signature branch 2 times, most recently from dd046d5 to 29e5bd6 Compare July 8, 2025 20:46
Comment on lines 674 to 678
let offer_tlv_stream = TlvStream::new(&self.bytes).range(OFFER_TYPES);
let experimental_offer_tlv_stream = TlvStream::new(&self.experimental_bytes).range(EXPERIMENTAL_OFFER_TYPES);
let combined_tlv_stream = offer_tlv_stream.chain(experimental_offer_tlv_stream);
let tagged_hash = TaggedHash::from_tlv_stream("LDK Offer ID", combined_tlv_stream);
Some(OfferId(tagged_hash.to_bytes()))
Copy link
Contributor

Choose a reason for hiding this comment

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

You can reuse OfferId::from_valid_invreq_tlv_stream (or something with a more appropriate name) rather than duplicating that code.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Make sense thanks!

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Done created the from_invoice_bytes that IMHO made clear that we look at the range of the offer TLV, please let me know if you have any other suggestion on this

@@ -41,7 +41,7 @@ impl TaggedHash {
/// Creates a tagged hash with the given parameters.
///
/// Panics if `bytes` is not a well-formed TLV stream containing at least one TLV record.
pub(super) fn from_valid_tlv_stream_bytes(tag: &'static str, bytes: &[u8]) -> Self {
pub fn from_valid_tlv_stream_bytes(tag: &'static str, bytes: &[u8]) -> Self {
Copy link
Contributor

Choose a reason for hiding this comment

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

Ok implemented it @jkczyz cfd6ad4 let me know what do you think.

Thanks, left some comments.

However, I still think that ldk should expose a way to verify the signature with the public key that is inside the offer, at least till we do not get a better PoP with bolt12

Given the signature is verified when parsing, shouldn't it be sufficient to check that Bolt12Invoice::signing_pubkey matches the one in the offer? The parser is already doing this when calling check_invoice_signing_pubkey, too, FWIW. Not sure I understand why we need to expose these for the user to piece together and call on their own.

At very least, there doesn't seem to be a need to expose TaggedHash::from_valid_tlv_stream_bytes if we can just have a method on Bolt12Invoice give the TaggedHash for manual signature verification.

@TheBlueMatt TheBlueMatt removed their request for review July 9, 2025 01:06
@TheBlueMatt
Copy link
Collaborator

Will take a look at this after @jkczyz's comments are addressed, assuming he thinks it needs a second reviewer at that point.

@vincenzopalazzo vincenzopalazzo force-pushed the macros/markle-signature branch from 0c7fe08 to 123e16f Compare July 9, 2025 10:19
@ldk-reviews-bot
Copy link

🔔 2nd Reminder

Hey @valentinewallace! This PR has been waiting for your review.
Please take a look when you have a chance. If you're unable to review, please let us know so we can find another reviewer.

@vincenzopalazzo vincenzopalazzo force-pushed the macros/markle-signature branch from 123e16f to 7429b6a Compare July 9, 2025 11:11
This is helpfull for the users that want to use the merkle tree
signature in their own code, for example to verify the
signature of bolt12 invoices or recreate it.

Very useful for people that are building command line tools
for the bolt12 offers.

Signed-off-by: Vincenzo Palazzo <[email protected]>
@vincenzopalazzo vincenzopalazzo force-pushed the macros/markle-signature branch from 7429b6a to 1b958e9 Compare July 9, 2025 11:13
@vincenzopalazzo
Copy link
Contributor Author

vincenzopalazzo commented Jul 9, 2025

Ok, I pushed the change that @jkczyz requested, and now the PR is organised in two commits as follows:

  • bed3812 where expose the sign and verify method to verify manually an invoice signature, this is nice for people that are building command line tools and want to show how the signature verification is described inside the spec
    -699f37fd231cdd8a83a69319900556ce9a7f3887 expose the offer_id inside the Bolt12Invoice that allows to compare it with the Offer to make sure that the invoice is linked with the Bolt12 one. This is useful for a sanity check, but requires trusting that the decoding of ldk will always verify the signature of the invoice

Let me know if you are fine with the commit divided in this way, or you prefer just699f37fd231cdd8a83a69319900556ce9a7f3887

- Add an Option<OfferId> field to Bolt12Invoice to track the originating offer.
- Compute the offer_id for invoices created from offers by extracting the offer TLV records and hashing them with the correct tag.
- Expose a public offer_id() accessor on Bolt12Invoice.
- Add tests to ensure the offer_id in the invoice matches the originating Offer, and that refund invoices have no offer_id.
- All existing and new tests pass. This enables linking invoices to their originating offers in a robust and spec-compliant way.

Signed-off-by: Vincenzo Palazzo <[email protected]>
@vincenzopalazzo vincenzopalazzo force-pushed the macros/markle-signature branch from 1b958e9 to 699f37f Compare July 9, 2025 11:23
Comment on lines +1643 to +1644
let offer_id = OfferId::from_invoice_bytes(&bytes);
Ok(Bolt12Invoice { bytes, contents, signature, tagged_hash, offer_id: Some(offer_id) })
Copy link

Choose a reason for hiding this comment

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

The current implementation unconditionally sets offer_id: Some(offer_id) for all invoices, but this doesn't match the behavior defined in compute_offer_id(). According to that method, only invoices corresponding to an offer should have an offer ID, while refund invoices should have None.

Consider updating this code to check the invoice contents type before setting the offer ID:

let offer_id = match &contents {
    InvoiceContents::ForOffer { .. } => Some(OfferId::from_invoice_bytes(&bytes)),
    InvoiceContents::ForRefund { .. } => None,
};

This would ensure consistent behavior between the two code paths and maintain the correct semantics for refund invoices.

Suggested change
let offer_id = OfferId::from_invoice_bytes(&bytes);
Ok(Bolt12Invoice { bytes, contents, signature, tagged_hash, offer_id: Some(offer_id) })
let offer_id = match &contents {
InvoiceContents::ForOffer { .. } => Some(OfferId::from_invoice_bytes(&bytes)),
InvoiceContents::ForRefund { .. } => None,
};
Ok(Bolt12Invoice { bytes, contents, signature, tagged_hash, offer_id })

Spotted by Diamond

Is this helpful? React 👍 or 👎 to let us know.

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.

6 participants