Skip to content

Implement parsing & resolving lnurls #2

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

Merged
merged 1 commit into from
Jul 2, 2025
Merged
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
11 changes: 10 additions & 1 deletion fuzz/src/parse.rs
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,16 @@ impl HrnResolver for Resolver<'_> {
})
}

fn resolve_lnurl<'a>(&'a self, _: String, _: Amount, _: [u8; 32]) -> LNURLResolutionFuture<'a> {
fn resolve_lnurl<'a>(&'a self, _: &'a str) -> HrnResolutionFuture<'a> {
Box::pin(async {
let mut us = self.0.lock().unwrap();
us.0.take().unwrap()
})
}

fn resolve_lnurl_to_invoice<'a>(
&'a self, _: String, _: Amount, _: [u8; 32],
) -> LNURLResolutionFuture<'a> {
Box::pin(async {
let mut us = self.0.lock().unwrap();
if let Ok(s) = std::str::from_utf8(us.1.take().unwrap()) {
Expand Down
13 changes: 10 additions & 3 deletions src/dns_resolver.rs
Original file line number Diff line number Diff line change
Expand Up @@ -39,9 +39,16 @@ impl HrnResolver for DNSHrnResolver {
Box::pin(async move { self.resolve_dns(hrn).await })
}

fn resolve_lnurl<'a>(&'a self, _: String, _: Amount, _: [u8; 32]) -> LNURLResolutionFuture<'a> {
let err = "resolve_lnurl shouldn't be called when we don't reoslve LNURL";
debug_assert!(false, "{}", err);
fn resolve_lnurl<'a>(&'a self, _url: &'a str) -> HrnResolutionFuture<'a> {
let err = "DNS resolver does not support LNURL resolution";
Box::pin(async move { Err(err) })
}

fn resolve_lnurl_to_invoice<'a>(
&'a self, _: String, _: Amount, _: [u8; 32],
) -> LNURLResolutionFuture<'a> {
let err = "resolve_lnurl_to_invoice shouldn't be called when we don't resolve LNURL";
debug_assert!(false, "{err}");
Box::pin(async move { Err(err) })
}
}
Expand Down
14 changes: 12 additions & 2 deletions src/hrn_resolution.rs
Original file line number Diff line number Diff line change
Expand Up @@ -111,10 +111,14 @@ pub trait HrnResolver {
/// can be further parsed as payment instructions.
fn resolve_hrn<'a>(&'a self, hrn: &'a HumanReadableName) -> HrnResolutionFuture<'a>;

/// Resolves the given Lnurl into a [`HrnResolution`] containing a result which
Copy link
Member

Choose a reason for hiding this comment

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

An Lnurl isn't a well-defined thing, I believe? This needs to specify what, exactly, the url parameter is (a URL for an LNURL endpoint, and not the encoded LNURL...... strings).

Copy link
Contributor Author

Choose a reason for hiding this comment

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

An lnurl is just a bech32 encoded url https://github.com/lnurl/luds/blob/luds/01.md

Everything works off of calling the first first URL as a GET request and then seeing which kind it is and handling it from there. This function is that first GET request

/// can be further parsed as payment instructions.
fn resolve_lnurl<'a>(&'a self, url: &'a str) -> HrnResolutionFuture<'a>;
Copy link
Member

Choose a reason for hiding this comment

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

Should we rename the trait now that its resolving more than HRNs? I think I'm fine with no, because we really want this to be about HRNs, we just happen to support LNURL too, but still.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Yeah I figured it was simpler to keep the name. At this point basically every lnurl-pay is a lightning address, for most cases this is just splitting out the 2 lnurl calls into separate functions instead of a single one


/// Resolves the LNURL callback (from a [`HrnResolution::LNURLPay`]) into a [`Bolt11Invoice`].
///
/// This shall only be called if [`Self::resolve_hrn`] returns an [`HrnResolution::LNURLPay`].
fn resolve_lnurl<'a>(
fn resolve_lnurl_to_invoice<'a>(
&'a self, callback_url: String, amount: Amount, expected_description_hash: [u8; 32],
) -> LNURLResolutionFuture<'a>;
}
Expand All @@ -128,7 +132,13 @@ impl HrnResolver for DummyHrnResolver {
Box::pin(async { Err("Human Readable Name resolution not supported") })
}

fn resolve_lnurl<'a>(&'a self, _: String, _: Amount, _: [u8; 32]) -> LNURLResolutionFuture<'a> {
fn resolve_lnurl<'a>(&'a self, _lnurl: &'a str) -> HrnResolutionFuture<'a> {
Box::pin(async { Err("LNURL resolution not supported") })
}

fn resolve_lnurl_to_invoice<'a>(
&'a self, _: String, _: Amount, _: [u8; 32],
) -> LNURLResolutionFuture<'a> {
Box::pin(async { Err("LNURL resolution not supported") })
}
}
60 changes: 52 additions & 8 deletions src/http_resolver.rs
Original file line number Diff line number Diff line change
Expand Up @@ -134,17 +134,16 @@ impl HTTPHrnResolver {
resolve_proof(&dns_name, proof)
}

async fn resolve_lnurl(&self, hrn: &HumanReadableName) -> Result<HrnResolution, &'static str> {
let init_url = format!("https://{}/.well-known/lnurlp/{}", hrn.domain(), hrn.user());
async fn resolve_lnurl_impl(&self, lnurl_url: &str) -> Result<HrnResolution, &'static str> {
let err = "Failed to fetch LN-Address initial well-known endpoint";
let init: LNURLInitResponse =
reqwest::get(init_url).await.map_err(|_| err)?.json().await.map_err(|_| err)?;
reqwest::get(lnurl_url).await.map_err(|_| err)?.json().await.map_err(|_| err)?;

if init.tag != "payRequest" {
return Err("LNURL initial init_responseponse had an incorrect tag value");
return Err("LNURL initial init_response had an incorrect tag value");
}
if init.min_sendable > init.max_sendable {
return Err("LNURL initial init_responseponse had no sendable amounts");
return Err("LNURL initial init_response had no sendable amounts");
}

let err = "LNURL metadata was not in the correct format";
Expand Down Expand Up @@ -176,14 +175,20 @@ impl HrnResolver for HTTPHrnResolver {
Err(e) if e == DNS_ERR => {
// If we got an error that might indicate the recipient doesn't support BIP
// 353, try LN-Address via LNURL
self.resolve_lnurl(hrn).await
let init_url =
format!("https://{}/.well-known/lnurlp/{}", hrn.domain(), hrn.user());
self.resolve_lnurl(&init_url).await
},
Err(e) => Err(e),
}
})
}

fn resolve_lnurl<'a>(
fn resolve_lnurl<'a>(&'a self, url: &'a str) -> HrnResolutionFuture<'a> {
Box::pin(async move { self.resolve_lnurl_impl(url).await })
}

fn resolve_lnurl_to_invoice<'a>(
&'a self, mut callback: String, amt: Amount, expected_description_hash: [u8; 32],
) -> LNURLResolutionFuture<'a> {
Box::pin(async move {
Expand Down Expand Up @@ -308,7 +313,8 @@ mod tests {
.unwrap();

let resolved = if let PaymentInstructions::ConfigurableAmount(instr) = instructions {
// min_amt and max_amt may or may not be set by the LNURL server
assert!(instr.min_amt().is_some());
assert!(instr.max_amt().is_some());

assert_eq!(instr.pop_callback(), None);
assert!(instr.bip_353_dnssec_proof().is_none());
Expand Down Expand Up @@ -339,4 +345,42 @@ mod tests {
}
}
}

#[tokio::test]
async fn test_http_lnurl_resolver() {
let instructions = PaymentInstructions::parse(
// lnurl encoding for [email protected]
"lnurl1dp68gurn8ghj7cnfw33k76tw9ehxjmn2vyhjuam9d3kz66mwdamkutmvde6hymrs9akxuatjd36x2um5ahcq39",
Network::Bitcoin,
&HTTPHrnResolver,
true,
)
.await
.unwrap();

let resolved = if let PaymentInstructions::ConfigurableAmount(instr) = instructions {
assert!(instr.min_amt().is_some());
assert!(instr.max_amt().is_some());

assert_eq!(instr.pop_callback(), None);
assert!(instr.bip_353_dnssec_proof().is_none());

instr.set_amount(Amount::from_sats(100_000).unwrap(), &HTTPHrnResolver).await.unwrap()
} else {
panic!();
};

assert_eq!(resolved.pop_callback(), None);
assert!(resolved.bip_353_dnssec_proof().is_none());

for method in resolved.methods() {
match method {
PaymentMethod::LightningBolt11(invoice) => {
assert_eq!(invoice.amount_milli_satoshis(), Some(100_000_000));
},
PaymentMethod::LightningBolt12(_) => panic!("Should only resolve to BOLT 11"),
PaymentMethod::OnChain(_) => panic!("Should only resolve to BOLT 11"),
}
}
}
}
99 changes: 98 additions & 1 deletion src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -347,7 +347,8 @@ impl ConfigurableAmountPaymentInstructions {
debug_assert!(inner.onchain_amt.is_none());
debug_assert!(inner.pop_callback.is_none());
debug_assert!(inner.hrn_proof.is_none());
let bolt11 = resolver.resolve_lnurl(callback, amount, expected_desc_hash).await?;
let bolt11 =
resolver.resolve_lnurl_to_invoice(callback, amount, expected_desc_hash).await?;
if bolt11.amount_milli_satoshis() != Some(amount.milli_sats()) {
return Err("LNURL resolution resulted in a BOLT 11 invoice with the wrong amount");
}
Expand Down Expand Up @@ -428,6 +429,8 @@ pub enum ParseError {
InvalidBolt12(Bolt12ParseError),
/// An invalid on-chain address was encountered
InvalidOnChain(address::ParseError),
/// An invalid lnurl was encountered
InvalidLnurl(&'static str),
/// The payment instructions encoded instructions for a network other than the one specified.
WrongNetwork,
/// Different parts of the payment instructions were inconsistent.
Expand Down Expand Up @@ -944,6 +947,55 @@ impl PaymentInstructions {
))
},
}
} else if let Some(idx) = instructions.to_lowercase().rfind("lnurl") {
let mut lnurl_str = &instructions[idx..];
Comment on lines +950 to +951
Copy link
Member

Choose a reason for hiding this comment

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

This assumes that unicode lowercasing will always keep the char the same length. I assume that's probably true in practice, but its a weird assumption to take. Instead, using to_ascii_lowercase only assumes that for ASCII chars (which is trivially true).

// first try to decode as a bech32-encoded lnurl, if that fails, try to drop a
// trailing `&` and decode again, this could a http query param
if let Some(idx) = lnurl_str.find('&') {
lnurl_str = &lnurl_str[..idx];
}
if let Some(idx) = lnurl_str.find('#') {
lnurl_str = &lnurl_str[..idx];
}
if let Ok((_, data)) = bitcoin::bech32::decode(lnurl_str) {
Copy link
Member

Choose a reason for hiding this comment

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

Does it need to be from lnurl to the end of the string, or can it be a substring?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Made it so it'll handle relevant sub strings

let url = String::from_utf8(data)
.map_err(|_| ParseError::InvalidLnurl("Not utf-8 encoded string"))?;
let resolution = hrn_resolver.resolve_lnurl(&url).await;
let resolution = resolution.map_err(ParseError::HrnResolutionError)?;
match resolution {
HrnResolution::DNSSEC { .. } => Err(ParseError::HrnResolutionError(
"Unexpected return when resolving lnurl",
)),
HrnResolution::LNURLPay {
min_value,
max_value,
expected_description_hash,
recipient_description,
callback,
} => {
let inner = PaymentInstructionsImpl {
description: recipient_description,
methods: Vec::new(),
lnurl: Some((
callback,
expected_description_hash,
min_value,
max_value,
)),
onchain_amt: None,
ln_amt: None,
pop_callback: None,
hrn: None,
hrn_proof: None,
};
Ok(PaymentInstructions::ConfigurableAmount(
ConfigurableAmountPaymentInstructions { inner },
))
},
}
} else {
parse_resolved_instructions(instructions, network, supports_pops, None, None)
}
} else {
parse_resolved_instructions(instructions, network, supports_pops, None, None)
}
Expand All @@ -966,6 +1018,19 @@ mod tests {
const SAMPLE_OFFER: &str = "lno1qgs0v8hw8d368q9yw7sx8tejk2aujlyll8cp7tzzyh5h8xyppqqqqqqgqvqcdgq2qenxzatrv46pvggrv64u366d5c0rr2xjc3fq6vw2hh6ce3f9p7z4v4ee0u7avfynjw9q";
const SAMPLE_BIP21: &str = "bitcoin:1andreas3batLhQa2FawWjeyjCqyBzypd?amount=50&label=Luke-Jr&message=Donation%20for%20project%20xyz";

#[cfg(feature = "http")]
const SAMPLE_LNURL: &str = "LNURL1DP68GURN8GHJ7MRWW4EXCTNDW46XJMNEDEJHGTNRDAKJ7TNHV4KXCTTTDEHHWM30D3H82UNVWQHHYETXW4HXG0AH8NK";
#[cfg(feature = "http")]
const SAMPLE_LNURL_LN_PREFIX: &str = "lightning:LNURL1DP68GURN8GHJ7MRWW4EXCTNDW46XJMNEDEJHGTNRDAKJ7TNHV4KXCTTTDEHHWM30D3H82UNVWQHHYETXW4HXG0AH8NK";
#[cfg(feature = "http")]
const SAMPLE_LNURL_FALLBACK: &str = "https://service.com/giftcard/redeem?id=123&lightning=LNURL1DP68GURN8GHJ7MRWW4EXCTNDW46XJMNEDEJHGTNRDAKJ7TNHV4KXCTTTDEHHWM30D3H82UNVWQHHYETXW4HXG0AH8NK";
#[cfg(feature = "http")]
const SAMPLE_LNURL_FALLBACK_WITH_AND: &str = "https://service.com/giftcard/redeem?id=123&lightning=LNURL1DP68GURN8GHJ7MRWW4EXCTNDW46XJMNEDEJHGTNRDAKJ7TNHV4KXCTTTDEHHWM30D3H82UNVWQHHYETXW4HXG0AH8NK&extra=my_extra_param";
#[cfg(feature = "http")]
const SAMPLE_LNURL_FALLBACK_WITH_HASHTAG: &str = "https://service.com/giftcard/redeem?id=123&lightning=LNURL1DP68GURN8GHJ7MRWW4EXCTNDW46XJMNEDEJHGTNRDAKJ7TNHV4KXCTTTDEHHWM30D3H82UNVWQHHYETXW4HXG0AH8NK#extra=my_extra_param";
#[cfg(feature = "http")]
const SAMPLE_LNURL_FALLBACK_WITH_BOTH: &str = "https://service.com/giftcard/redeem?id=123&lightning=LNURL1DP68GURN8GHJ7MRWW4EXCTNDW46XJMNEDEJHGTNRDAKJ7TNHV4KXCTTTDEHHWM30D3H82UNVWQHHYETXW4HXG0AH8NK&extra=my_extra_param#extra2=another_extra_param";

const SAMPLE_BIP21_WITH_INVOICE: &str = "bitcoin:BC1QYLH3U67J673H6Y6ALV70M0PL2YZ53TZHVXGG7U?amount=0.00001&label=sbddesign%3A%20For%20lunch%20Tuesday&message=For%20lunch%20Tuesday&lightning=LNBC10U1P3PJ257PP5YZTKWJCZ5FTL5LAXKAV23ZMZEKAW37ZK6KMV80PK4XAEV5QHTZ7QDPDWD3XGER9WD5KWM36YPRX7U3QD36KUCMGYP282ETNV3SHJCQZPGXQYZ5VQSP5USYC4LK9CHSFP53KVCNVQ456GANH60D89REYKDNGSMTJ6YW3NHVQ9QYYSSQJCEWM5CJWZ4A6RFJX77C490YCED6PEMK0UPKXHY89CMM7SCT66K8GNEANWYKZGDRWRFJE69H9U5U0W57RRCSYSAS7GADWMZXC8C6T0SPJAZUP6";
#[cfg(not(feature = "std"))]
const SAMPLE_BIP21_WITH_INVOICE_ADDR: &str = "bc1qylh3u67j673h6y6alv70m0pl2yz53tzhvxgg7u";
Expand Down Expand Up @@ -1277,4 +1342,36 @@ mod tests {
Err(ParseError::InstructionsExpired),
);
}

#[cfg(feature = "http")]
async fn test_lnurl(str: &str) {
let parsed = PaymentInstructions::parse(
str,
Network::Signet,
&http_resolver::HTTPHrnResolver,
false,
)
.await
.unwrap();

let parsed = match parsed {
PaymentInstructions::ConfigurableAmount(parsed) => parsed,
_ => panic!(),
};

assert_eq!(parsed.methods().count(), 1);
assert_eq!(parsed.min_amt(), Some(Amount::from_milli_sats(1000).unwrap()));
assert_eq!(parsed.max_amt(), Some(Amount::from_milli_sats(11000000000).unwrap()));
}

#[cfg(feature = "http")]
#[tokio::test]
async fn parse_lnurl() {
test_lnurl(SAMPLE_LNURL).await;
test_lnurl(SAMPLE_LNURL_LN_PREFIX).await;
test_lnurl(SAMPLE_LNURL_FALLBACK).await;
test_lnurl(SAMPLE_LNURL_FALLBACK_WITH_AND).await;
test_lnurl(SAMPLE_LNURL_FALLBACK_WITH_HASHTAG).await;
test_lnurl(SAMPLE_LNURL_FALLBACK_WITH_BOTH).await;
}
}