From a8644fe43d32b5020ff4cfea3c1bd0d235b58a39 Mon Sep 17 00:00:00 2001 From: benthecarman Date: Thu, 24 Apr 2025 17:19:48 -0700 Subject: [PATCH] Implement parsing & resolving lnurls --- fuzz/src/parse.rs | 11 +++++++- src/dns_resolver.rs | 12 +++++++-- src/hrn_resolution.rs | 14 ++++++++-- src/http_resolver.rs | 60 +++++++++++++++++++++++++++++++++++++------ src/lib.rs | 37 +++++++++++++++++++++++++- 5 files changed, 120 insertions(+), 14 deletions(-) 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..b4f19dc 100644 --- a/src/dns_resolver.rs +++ b/src/dns_resolver.rs @@ -39,8 +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"; + fn resolve_lnurl<'a>(&'a self, _url: &'a str) -> HrnResolutionFuture<'a> { + let err = "resolve_lnurl shouldn't be called when we don't resolve LNURL"; + debug_assert!(false, "{}", err); + Box::pin(async move { Err(err) }) + } + + fn resolve_lnurl_to_invoice<'a>( + &'a self, _: String, _: Amount, _: [u8; 32], + ) -> LNURLResolutionFuture<'a> { + let err = "resolve_lnurl 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..f331472 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,38 @@ impl PaymentInstructions { )) }, } + } else if let Some((_, data)) = + bitcoin::bech32::decode(instructions).ok().filter(|(hrp, _)| hrp.as_str() == "lnurl") + { + let url = String::from_utf8(data).map_err(|_| ParseError::InvalidLnurl(""))?; + 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) }