diff --git a/fuzz/src/parse.rs b/fuzz/src/parse.rs index 8850ce9..6252e34 100644 --- a/fuzz/src/parse.rs +++ b/fuzz/src/parse.rs @@ -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()) { diff --git a/src/dns_resolver.rs b/src/dns_resolver.rs index edefb2f..16ca973 100644 --- a/src/dns_resolver.rs +++ b/src/dns_resolver.rs @@ -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) }) } } diff --git a/src/hrn_resolution.rs b/src/hrn_resolution.rs index 6563cc8..1d47a03 100644 --- a/src/hrn_resolution.rs +++ b/src/hrn_resolution.rs @@ -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 + /// can be further parsed as payment instructions. + fn resolve_lnurl<'a>(&'a self, url: &'a str) -> HrnResolutionFuture<'a>; + /// 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>; } @@ -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") }) } } diff --git a/src/http_resolver.rs b/src/http_resolver.rs index a4b5e2e..ea02610 100644 --- a/src/http_resolver.rs +++ b/src/http_resolver.rs @@ -134,17 +134,16 @@ impl HTTPHrnResolver { resolve_proof(&dns_name, proof) } - async fn resolve_lnurl(&self, hrn: &HumanReadableName) -> Result { - let init_url = format!("https://{}/.well-known/lnurlp/{}", hrn.domain(), hrn.user()); + async fn resolve_lnurl_impl(&self, lnurl_url: &str) -> Result { 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"; @@ -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 { @@ -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()); @@ -339,4 +345,42 @@ mod tests { } } } + + #[tokio::test] + async fn test_http_lnurl_resolver() { + let instructions = PaymentInstructions::parse( + // lnurl encoding for lnurltest@bitcoin.ninja + "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"), + } + } + } } diff --git a/src/lib.rs b/src/lib.rs index 64f2e8e..04dcd07 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -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"); } @@ -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. @@ -944,6 +947,55 @@ impl PaymentInstructions { )) }, } + } else if let Some(idx) = instructions.to_lowercase().rfind("lnurl") { + let mut lnurl_str = &instructions[idx..]; + // 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) { + 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) } @@ -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"; @@ -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; + } }