Skip to content

offers: parse invoice and invoice request #3800

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 1 commit into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 42 additions & 1 deletion lightning/src/offers/invoice.rs
Original file line number Diff line number Diff line change
Expand Up @@ -138,7 +138,7 @@ use crate::offers::offer::{
Amount, ExperimentalOfferTlvStream, ExperimentalOfferTlvStreamRef, OfferTlvStream,
OfferTlvStreamRef, Quantity, EXPERIMENTAL_OFFER_TYPES, OFFER_TYPES,
};
use crate::offers::parse::{Bolt12ParseError, Bolt12SemanticError, ParsedMessage};
use crate::offers::parse::{Bech32Encode, Bolt12ParseError, Bolt12SemanticError, ParsedMessage};
use crate::offers::payer::{PayerTlvStream, PayerTlvStreamRef, PAYER_METADATA_TYPE};
use crate::offers::refund::{
Refund, RefundContents, IV_BYTES_WITHOUT_METADATA as REFUND_IV_BYTES_WITHOUT_METADATA,
Expand All @@ -158,6 +158,7 @@ use bitcoin::secp256k1::schnorr::Signature;
use bitcoin::secp256k1::{self, Keypair, PublicKey, Secp256k1};
use bitcoin::{Network, WitnessProgram, WitnessVersion};
use core::hash::{Hash, Hasher};
use core::str::FromStr;
use core::time::Duration;

#[allow(unused_imports)]
Expand Down Expand Up @@ -1416,6 +1417,30 @@ impl Writeable for InvoiceContents {
}
}

impl AsRef<[u8]> for Bolt12Invoice {
fn as_ref(&self) -> &[u8] {
&self.bytes
}
}

impl Bech32Encode for Bolt12Invoice {
const BECH32_HRP: &'static str = "lni";
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 shouldn't introduce a bech32 encoding that isn't in the spec. BOLT12 invoices are only transferred over the wire, so one isn't defined. They also only make sense in the context of a preceding offer / invoice_request (i.e., you'll never see them in a context where a user would scan it).

Copy link
Contributor

Choose a reason for hiding this comment

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

FWIW, could that be a spec bug? Seems CLN is supporting the lni format, so maybe it should be added to the spec, or was that omitted on purpose?

Copy link
Contributor

Choose a reason for hiding this comment

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

It was deliberately removed from the spec. IIRC, @TheBlueMatt pushed for having it removed. CLN uses it in some tests and its CLI, I believe.

}

impl FromStr for Bolt12Invoice {
type Err = Bolt12ParseError;

fn from_str(s: &str) -> Result<Self, <Self as FromStr>::Err> {
Self::from_bech32_str(s)
}
}

impl core::fmt::Display for Bolt12Invoice {
fn fmt(&self, f: &mut core::fmt::Formatter) -> Result<(), core::fmt::Error> {
self.fmt_bech32_str(f)
}
}

impl TryFrom<Vec<u8>> for UnsignedBolt12Invoice {
type Error = Bolt12ParseError;

Expand Down Expand Up @@ -2572,6 +2597,22 @@ mod tests {
}
}

#[test]
fn parses_bech32_encoded_invoices() {
let invoices = [
"lni1qqsg7jpsyzz4hcsj0hu6rvjevwhmkceurq7sd5ez8ne3js4qt8acvxcgqgp7szsqzcss9w6ckhlv55zuwnkuqqxc9qhu24h9rggzflyw04l9d3hcslzu340jtqss9l7txvy6ukzg8zkxdnvzmg2at4stt004vdqrm0zedsez596nf5w55r7sr3qzhfe2d696205tjuddpjvz8952aaxh3n527f26ks7llqcq8jgzlwxsxhzwphk8y90zdqee8pesuhjst2nz2px6ska9wyr2g666ysz0e8vwqgptkk94lm99qhr5ahqqpkpg9lz4deg6zqj0erna0etvd7y8chydtusq9vqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqz3afsfc3h8etwulthfjufa8c6lm8saelrud6h7xyeprcxnk4rd3sqqtqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq46w2nw3wjnazuhrtgvnq3edzh0f4uvazhj2k458hlcxqpujqhm35p4cnsda3eptcngxwfcwv89u5z65cjsfk59hft3q6jxkk3yqn7fmrszqw2gk576jl7lvaxqsae3tt9uepmp4gae5kptgwvc97a04jvljuss7qpdqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq0vc5l00vl5rwqgc7cmxyrgtuz8dvv6yma5qs2609uvyfe7wvq2gxqpwqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqz3rsqqqqqqsqqqraqqz5qqqqqqqqqqqvsqqqq8g6jj3qqqqqqqqqqqpqqqq86qq9gqqqqqqqqqqqeqqqqqw3499zqqqqq9yq35rr8sh4qsz52329g4z52329g4z52329g4z52329g4z52329g4z52329g4z5242qgp73tzaqqqzpcasc3pf3lquzjd0haxgn9hmjfp84eq7geymjdx2f9verdu99wz4qqqpf67qrgen88wz7kzlkpyp480l5rgzecaz2qgqyza43d07efg9ca8dcqqds2p0c4tw2xssyn7gult72mr03p79er2l9vppq2a43d07efg9ca8dcqqds2p0c4tw2xssyn7gult72mr03p79er2l9uzq9wktr4p2qxgmdnpw8qvs05qr0zvam2h52lxt4zz7lah7yp6vmsczevlvqdgjxtwdlp84304uqcygvqcgzpj8p44smqjpzeua0xryrrc"
];
for encoded in invoices {
let decoded = match encoded.parse::<Bolt12Invoice>() {
Ok(decoded) => decoded,
Err(e) => panic!("Invalid invoice ({:?}): {}", e, encoded),
};

let reencoded = decoded.to_string();
assert_eq!(reencoded, encoded, "Re-encoded invoice does not match original");
}
}

#[test]
fn parses_invoice_with_payment_paths() {
let expanded_key = ExpandedKey::new([42; 32]);
Expand Down
44 changes: 43 additions & 1 deletion lightning/src/offers/invoice_request.rs
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,8 @@
//! # }
//! ```

use core::str::FromStr;

use crate::blinded_path::message::BlindedMessagePath;
use crate::blinded_path::payment::BlindedPaymentPath;
use crate::io;
Expand All @@ -79,7 +81,7 @@ use crate::offers::offer::{
Amount, ExperimentalOfferTlvStream, ExperimentalOfferTlvStreamRef, Offer, OfferContents,
OfferId, OfferTlvStream, OfferTlvStreamRef, EXPERIMENTAL_OFFER_TYPES, OFFER_TYPES,
};
use crate::offers::parse::{Bolt12ParseError, Bolt12SemanticError, ParsedMessage};
use crate::offers::parse::{Bech32Encode, Bolt12ParseError, Bolt12SemanticError, ParsedMessage};
use crate::offers::payer::{PayerContents, PayerTlvStream, PayerTlvStreamRef};
use crate::offers::signer::{Metadata, MetadataMaterial};
use crate::onion_message::dns_resolution::HumanReadableName;
Expand Down Expand Up @@ -1284,6 +1286,30 @@ impl TryFrom<Vec<u8>> for UnsignedInvoiceRequest {
}
}

impl AsRef<[u8]> for InvoiceRequest {
fn as_ref(&self) -> &[u8] {
&self.bytes
}
}

impl Bech32Encode for InvoiceRequest {
const BECH32_HRP: &'static str = "lnr";
Copy link
Contributor

Choose a reason for hiding this comment

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

Similarly here. Additionally, this clashes with Refund, which is essentially an invoice_request without a preceding offer.

Copy link
Contributor

@tnull tnull May 28, 2025

Choose a reason for hiding this comment

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

This one is in the spec though, or at least it makes it seem lnr is used for both?:

Invoice Requests are a request for an invoice; the human-readable prefix for invoice requests is lnr.

https://github.com/lightning/bolts/blob/master/12-offer-encoding.md#invoice-requests

Copy link
Contributor

Choose a reason for hiding this comment

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

I raised the issue at one point: lightning/bolts#798 (comment)

Ultimately, if you overload it, then your parser can't just look at the human-readable part to determine which code to call. It needs to try parsing with one and, if it fails, fall back to the other.

}

impl FromStr for InvoiceRequest {
type Err = Bolt12ParseError;

fn from_str(s: &str) -> Result<Self, <Self as FromStr>::Err> {
Self::from_bech32_str(s)
}
}

impl core::fmt::Display for InvoiceRequest {
fn fmt(&self, f: &mut core::fmt::Formatter) -> Result<(), core::fmt::Error> {
self.fmt_bech32_str(f)
}
}

impl TryFrom<Vec<u8>> for InvoiceRequest {
type Error = Bolt12ParseError;

Expand Down Expand Up @@ -2219,6 +2245,22 @@ mod tests {
}
}

#[test]
fn parses_bech32_encoded_invoice_requests() {
let invoice_requests = [
"lnr1qqsg7jpsyzz4hcsj0hu6rvjevwhmkceurq7sd5ez8ne3js4qt8acvxcgqgp7szsqzsqpvggzhdvttlk22pw8fmwqqrvzst792mj35ypylj886ljkcmug03wg6he9yqs86ptqzqjcyypqk4jf95qryjcsqywr6kktzrf366ex4yp8cr5r8m32cre3kfea7w0sgzegrzqgucwd37cjyvkgg2lfae8j6wyyx7dj3aqe8j2ncrthhszl8r69lecma5cxclmft4kh8x39jaeqtdl2yy5gsfdqcpvxczf5x0sw"
];
for encoded in invoice_requests {
let decoded = match encoded.parse::<InvoiceRequest>() {
Ok(decoded) => decoded,
Err(e) => panic!("Invalid invoice request ({:?}): {}", e, encoded),
};

let reencoded = decoded.to_string();
assert_eq!(reencoded, encoded, "Re-encoded invoice does not match original");
}
}

#[test]
fn parses_invoice_request_with_metadata() {
let expanded_key = ExpandedKey::new([42; 32]);
Expand Down
19 changes: 1 addition & 18 deletions lightning/src/offers/merkle.rs
Original file line number Diff line number Diff line change
Expand Up @@ -285,10 +285,9 @@ mod tests {

use crate::ln::channelmanager::PaymentId;
use crate::ln::inbound_payment::ExpandedKey;
use crate::offers::invoice_request::{InvoiceRequest, UnsignedInvoiceRequest};
use crate::offers::invoice_request::UnsignedInvoiceRequest;
use crate::offers::nonce::Nonce;
use crate::offers::offer::{Amount, OfferBuilder};
use crate::offers::parse::Bech32Encode;
use crate::offers::signer::Metadata;
use crate::offers::test_utils::recipient_pubkey;
use crate::util::ser::Writeable;
Expand Down Expand Up @@ -477,20 +476,4 @@ mod tests {

assert_eq!(tlv_stream, invoice_request.bytes);
}

impl AsRef<[u8]> for InvoiceRequest {
fn as_ref(&self) -> &[u8] {
&self.bytes
}
}

impl Bech32Encode for InvoiceRequest {
const BECH32_HRP: &'static str = "lnr";
}

impl core::fmt::Display for InvoiceRequest {
fn fmt(&self, f: &mut core::fmt::Formatter) -> Result<(), core::fmt::Error> {
self.fmt_bech32_str(f)
}
}
}