Skip to content

Commit 09e000f

Browse files
committed
Implement parsing & resolving lnurls
1 parent dfa2b00 commit 09e000f

File tree

5 files changed

+182
-15
lines changed

5 files changed

+182
-15
lines changed

fuzz/src/parse.rs

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,16 @@ impl HrnResolver for Resolver<'_> {
4343
})
4444
}
4545

46-
fn resolve_lnurl<'a>(&'a self, _: String, _: Amount, _: [u8; 32]) -> LNURLResolutionFuture<'a> {
46+
fn resolve_lnurl<'a>(&'a self, _: &'a str) -> HrnResolutionFuture<'a> {
47+
Box::pin(async {
48+
let mut us = self.0.lock().unwrap();
49+
us.0.take().unwrap()
50+
})
51+
}
52+
53+
fn resolve_lnurl_to_invoice<'a>(
54+
&'a self, _: String, _: Amount, _: [u8; 32],
55+
) -> LNURLResolutionFuture<'a> {
4756
Box::pin(async {
4857
let mut us = self.0.lock().unwrap();
4958
if let Ok(s) = std::str::from_utf8(us.1.take().unwrap()) {

src/dns_resolver.rs

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -39,9 +39,16 @@ impl HrnResolver for DNSHrnResolver {
3939
Box::pin(async move { self.resolve_dns(hrn).await })
4040
}
4141

42-
fn resolve_lnurl<'a>(&'a self, _: String, _: Amount, _: [u8; 32]) -> LNURLResolutionFuture<'a> {
43-
let err = "resolve_lnurl shouldn't be called when we don't reoslve LNURL";
44-
debug_assert!(false, "{}", err);
42+
fn resolve_lnurl<'a>(&'a self, _url: &'a str) -> HrnResolutionFuture<'a> {
43+
let err = "DNS resolver does not support LNURL resolution";
44+
Box::pin(async move { Err(err) })
45+
}
46+
47+
fn resolve_lnurl_to_invoice<'a>(
48+
&'a self, _: String, _: Amount, _: [u8; 32],
49+
) -> LNURLResolutionFuture<'a> {
50+
let err = "resolve_lnurl_to_invoice shouldn't be called when we don't resolve LNURL";
51+
debug_assert!(false, "{err}");
4552
Box::pin(async move { Err(err) })
4653
}
4754
}

src/hrn_resolution.rs

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -111,10 +111,14 @@ pub trait HrnResolver {
111111
/// can be further parsed as payment instructions.
112112
fn resolve_hrn<'a>(&'a self, hrn: &'a HumanReadableName) -> HrnResolutionFuture<'a>;
113113

114+
/// Resolves the given Lnurl into a [`HrnResolution`] containing a result which
115+
/// can be further parsed as payment instructions.
116+
fn resolve_lnurl<'a>(&'a self, url: &'a str) -> HrnResolutionFuture<'a>;
117+
114118
/// Resolves the LNURL callback (from a [`HrnResolution::LNURLPay`]) into a [`Bolt11Invoice`].
115119
///
116120
/// This shall only be called if [`Self::resolve_hrn`] returns an [`HrnResolution::LNURLPay`].
117-
fn resolve_lnurl<'a>(
121+
fn resolve_lnurl_to_invoice<'a>(
118122
&'a self, callback_url: String, amount: Amount, expected_description_hash: [u8; 32],
119123
) -> LNURLResolutionFuture<'a>;
120124
}
@@ -128,7 +132,13 @@ impl HrnResolver for DummyHrnResolver {
128132
Box::pin(async { Err("Human Readable Name resolution not supported") })
129133
}
130134

131-
fn resolve_lnurl<'a>(&'a self, _: String, _: Amount, _: [u8; 32]) -> LNURLResolutionFuture<'a> {
135+
fn resolve_lnurl<'a>(&'a self, _lnurl: &'a str) -> HrnResolutionFuture<'a> {
136+
Box::pin(async { Err("LNURL resolution not supported") })
137+
}
138+
139+
fn resolve_lnurl_to_invoice<'a>(
140+
&'a self, _: String, _: Amount, _: [u8; 32],
141+
) -> LNURLResolutionFuture<'a> {
132142
Box::pin(async { Err("LNURL resolution not supported") })
133143
}
134144
}

src/http_resolver.rs

Lines changed: 52 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -134,17 +134,16 @@ impl HTTPHrnResolver {
134134
resolve_proof(&dns_name, proof)
135135
}
136136

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

143142
if init.tag != "payRequest" {
144-
return Err("LNURL initial init_responseponse had an incorrect tag value");
143+
return Err("LNURL initial init_response had an incorrect tag value");
145144
}
146145
if init.min_sendable > init.max_sendable {
147-
return Err("LNURL initial init_responseponse had no sendable amounts");
146+
return Err("LNURL initial init_response had no sendable amounts");
148147
}
149148

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

186-
fn resolve_lnurl<'a>(
187+
fn resolve_lnurl<'a>(&'a self, url: &'a str) -> HrnResolutionFuture<'a> {
188+
Box::pin(async move { self.resolve_lnurl_impl(url).await })
189+
}
190+
191+
fn resolve_lnurl_to_invoice<'a>(
187192
&'a self, mut callback: String, amt: Amount, expected_description_hash: [u8; 32],
188193
) -> LNURLResolutionFuture<'a> {
189194
Box::pin(async move {
@@ -308,7 +313,8 @@ mod tests {
308313
.unwrap();
309314

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

313319
assert_eq!(instr.pop_callback(), None);
314320
assert!(instr.bip_353_dnssec_proof().is_none());
@@ -339,4 +345,42 @@ mod tests {
339345
}
340346
}
341347
}
348+
349+
#[tokio::test]
350+
async fn test_http_lnurl_resolver() {
351+
let instructions = PaymentInstructions::parse(
352+
// lnurl encoding for [email protected]
353+
"lnurl1dp68gurn8ghj7cnfw33k76tw9ehxjmn2vyhjuam9d3kz66mwdamkutmvde6hymrs9akxuatjd36x2um5ahcq39",
354+
Network::Bitcoin,
355+
&HTTPHrnResolver,
356+
true,
357+
)
358+
.await
359+
.unwrap();
360+
361+
let resolved = if let PaymentInstructions::ConfigurableAmount(instr) = instructions {
362+
assert!(instr.min_amt().is_some());
363+
assert!(instr.max_amt().is_some());
364+
365+
assert_eq!(instr.pop_callback(), None);
366+
assert!(instr.bip_353_dnssec_proof().is_none());
367+
368+
instr.set_amount(Amount::from_sats(100_000).unwrap(), &HTTPHrnResolver).await.unwrap()
369+
} else {
370+
panic!();
371+
};
372+
373+
assert_eq!(resolved.pop_callback(), None);
374+
assert!(resolved.bip_353_dnssec_proof().is_none());
375+
376+
for method in resolved.methods() {
377+
match method {
378+
PaymentMethod::LightningBolt11(invoice) => {
379+
assert_eq!(invoice.amount_milli_satoshis(), Some(100_000_000));
380+
},
381+
PaymentMethod::LightningBolt12(_) => panic!("Should only resolve to BOLT 11"),
382+
PaymentMethod::OnChain(_) => panic!("Should only resolve to BOLT 11"),
383+
}
384+
}
385+
}
342386
}

src/lib.rs

Lines changed: 98 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -347,7 +347,8 @@ impl ConfigurableAmountPaymentInstructions {
347347
debug_assert!(inner.onchain_amt.is_none());
348348
debug_assert!(inner.pop_callback.is_none());
349349
debug_assert!(inner.hrn_proof.is_none());
350-
let bolt11 = resolver.resolve_lnurl(callback, amount, expected_desc_hash).await?;
350+
let bolt11 =
351+
resolver.resolve_lnurl_to_invoice(callback, amount, expected_desc_hash).await?;
351352
if bolt11.amount_milli_satoshis() != Some(amount.milli_sats()) {
352353
return Err("LNURL resolution resulted in a BOLT 11 invoice with the wrong amount");
353354
}
@@ -428,6 +429,8 @@ pub enum ParseError {
428429
InvalidBolt12(Bolt12ParseError),
429430
/// An invalid on-chain address was encountered
430431
InvalidOnChain(address::ParseError),
432+
/// An invalid lnurl was encountered
433+
InvalidLnurl(&'static str),
431434
/// The payment instructions encoded instructions for a network other than the one specified.
432435
WrongNetwork,
433436
/// Different parts of the payment instructions were inconsistent.
@@ -944,6 +947,55 @@ impl PaymentInstructions {
944947
))
945948
},
946949
}
950+
} else if let Some(idx) = instructions.to_lowercase().rfind("lnurl") {
951+
let mut lnurl_str = &instructions[idx..];
952+
// first try to decode as a bech32-encoded lnurl, if that fails, try to drop a
953+
// trailing `&` and decode again, this could a http query param
954+
if let Some(idx) = lnurl_str.find('&') {
955+
lnurl_str = &lnurl_str[..idx];
956+
}
957+
if let Some(idx) = lnurl_str.find('#') {
958+
lnurl_str = &lnurl_str[..idx];
959+
}
960+
if let Ok((_, data)) = bitcoin::bech32::decode(lnurl_str) {
961+
let url = String::from_utf8(data)
962+
.map_err(|_| ParseError::InvalidLnurl("Not utf-8 encoded string"))?;
963+
let resolution = hrn_resolver.resolve_lnurl(&url).await;
964+
let resolution = resolution.map_err(ParseError::HrnResolutionError)?;
965+
match resolution {
966+
HrnResolution::DNSSEC { .. } => Err(ParseError::HrnResolutionError(
967+
"Unexpected return when resolving lnurl",
968+
)),
969+
HrnResolution::LNURLPay {
970+
min_value,
971+
max_value,
972+
expected_description_hash,
973+
recipient_description,
974+
callback,
975+
} => {
976+
let inner = PaymentInstructionsImpl {
977+
description: recipient_description,
978+
methods: Vec::new(),
979+
lnurl: Some((
980+
callback,
981+
expected_description_hash,
982+
min_value,
983+
max_value,
984+
)),
985+
onchain_amt: None,
986+
ln_amt: None,
987+
pop_callback: None,
988+
hrn: None,
989+
hrn_proof: None,
990+
};
991+
Ok(PaymentInstructions::ConfigurableAmount(
992+
ConfigurableAmountPaymentInstructions { inner },
993+
))
994+
},
995+
}
996+
} else {
997+
parse_resolved_instructions(instructions, network, supports_pops, None, None)
998+
}
947999
} else {
9481000
parse_resolved_instructions(instructions, network, supports_pops, None, None)
9491001
}
@@ -966,6 +1018,19 @@ mod tests {
9661018
const SAMPLE_OFFER: &str = "lno1qgs0v8hw8d368q9yw7sx8tejk2aujlyll8cp7tzzyh5h8xyppqqqqqqgqvqcdgq2qenxzatrv46pvggrv64u366d5c0rr2xjc3fq6vw2hh6ce3f9p7z4v4ee0u7avfynjw9q";
9671019
const SAMPLE_BIP21: &str = "bitcoin:1andreas3batLhQa2FawWjeyjCqyBzypd?amount=50&label=Luke-Jr&message=Donation%20for%20project%20xyz";
9681020

1021+
#[cfg(feature = "http")]
1022+
const SAMPLE_LNURL: &str = "LNURL1DP68GURN8GHJ7MRWW4EXCTNDW46XJMNEDEJHGTNRDAKJ7TNHV4KXCTTTDEHHWM30D3H82UNVWQHHYETXW4HXG0AH8NK";
1023+
#[cfg(feature = "http")]
1024+
const SAMPLE_LNURL_LN_PREFIX: &str = "lightning:LNURL1DP68GURN8GHJ7MRWW4EXCTNDW46XJMNEDEJHGTNRDAKJ7TNHV4KXCTTTDEHHWM30D3H82UNVWQHHYETXW4HXG0AH8NK";
1025+
#[cfg(feature = "http")]
1026+
const SAMPLE_LNURL_FALLBACK: &str = "https://service.com/giftcard/redeem?id=123&lightning=LNURL1DP68GURN8GHJ7MRWW4EXCTNDW46XJMNEDEJHGTNRDAKJ7TNHV4KXCTTTDEHHWM30D3H82UNVWQHHYETXW4HXG0AH8NK";
1027+
#[cfg(feature = "http")]
1028+
const SAMPLE_LNURL_FALLBACK_WITH_AND: &str = "https://service.com/giftcard/redeem?id=123&lightning=LNURL1DP68GURN8GHJ7MRWW4EXCTNDW46XJMNEDEJHGTNRDAKJ7TNHV4KXCTTTDEHHWM30D3H82UNVWQHHYETXW4HXG0AH8NK&extra=my_extra_param";
1029+
#[cfg(feature = "http")]
1030+
const SAMPLE_LNURL_FALLBACK_WITH_HASHTAG: &str = "https://service.com/giftcard/redeem?id=123&lightning=LNURL1DP68GURN8GHJ7MRWW4EXCTNDW46XJMNEDEJHGTNRDAKJ7TNHV4KXCTTTDEHHWM30D3H82UNVWQHHYETXW4HXG0AH8NK#extra=my_extra_param";
1031+
#[cfg(feature = "http")]
1032+
const SAMPLE_LNURL_FALLBACK_WITH_BOTH: &str = "https://service.com/giftcard/redeem?id=123&lightning=LNURL1DP68GURN8GHJ7MRWW4EXCTNDW46XJMNEDEJHGTNRDAKJ7TNHV4KXCTTTDEHHWM30D3H82UNVWQHHYETXW4HXG0AH8NK&extra=my_extra_param#extra2=another_extra_param";
1033+
9691034
const SAMPLE_BIP21_WITH_INVOICE: &str = "bitcoin:BC1QYLH3U67J673H6Y6ALV70M0PL2YZ53TZHVXGG7U?amount=0.00001&label=sbddesign%3A%20For%20lunch%20Tuesday&message=For%20lunch%20Tuesday&lightning=LNBC10U1P3PJ257PP5YZTKWJCZ5FTL5LAXKAV23ZMZEKAW37ZK6KMV80PK4XAEV5QHTZ7QDPDWD3XGER9WD5KWM36YPRX7U3QD36KUCMGYP282ETNV3SHJCQZPGXQYZ5VQSP5USYC4LK9CHSFP53KVCNVQ456GANH60D89REYKDNGSMTJ6YW3NHVQ9QYYSSQJCEWM5CJWZ4A6RFJX77C490YCED6PEMK0UPKXHY89CMM7SCT66K8GNEANWYKZGDRWRFJE69H9U5U0W57RRCSYSAS7GADWMZXC8C6T0SPJAZUP6";
9701035
#[cfg(not(feature = "std"))]
9711036
const SAMPLE_BIP21_WITH_INVOICE_ADDR: &str = "bc1qylh3u67j673h6y6alv70m0pl2yz53tzhvxgg7u";
@@ -1277,4 +1342,36 @@ mod tests {
12771342
Err(ParseError::InstructionsExpired),
12781343
);
12791344
}
1345+
1346+
#[cfg(feature = "http")]
1347+
async fn test_lnurl(str: &str) {
1348+
let parsed = PaymentInstructions::parse(
1349+
str,
1350+
Network::Signet,
1351+
&http_resolver::HTTPHrnResolver,
1352+
false,
1353+
)
1354+
.await
1355+
.unwrap();
1356+
1357+
let parsed = match parsed {
1358+
PaymentInstructions::ConfigurableAmount(parsed) => parsed,
1359+
_ => panic!(),
1360+
};
1361+
1362+
assert_eq!(parsed.methods().count(), 1);
1363+
assert_eq!(parsed.min_amt(), Some(Amount::from_milli_sats(1000).unwrap()));
1364+
assert_eq!(parsed.max_amt(), Some(Amount::from_milli_sats(11000000000).unwrap()));
1365+
}
1366+
1367+
#[cfg(feature = "http")]
1368+
#[tokio::test]
1369+
async fn parse_lnurl() {
1370+
test_lnurl(SAMPLE_LNURL).await;
1371+
test_lnurl(SAMPLE_LNURL_LN_PREFIX).await;
1372+
test_lnurl(SAMPLE_LNURL_FALLBACK).await;
1373+
test_lnurl(SAMPLE_LNURL_FALLBACK_WITH_AND).await;
1374+
test_lnurl(SAMPLE_LNURL_FALLBACK_WITH_HASHTAG).await;
1375+
test_lnurl(SAMPLE_LNURL_FALLBACK_WITH_BOTH).await;
1376+
}
12801377
}

0 commit comments

Comments
 (0)