-
Notifications
You must be signed in to change notification settings - Fork 4
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
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -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>; | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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. There was a problem hiding this comment. Choose a reason for hiding this commentThe 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>; | ||
} | ||
|
@@ -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") }) | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -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"; | ||
|
@@ -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 [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"), | ||
} | ||
} | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -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..]; | ||
Comment on lines
+950
to
+951
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 |
||
// 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) { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Does it need to be from There was a problem hiding this comment. Choose a reason for hiding this commentThe 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) | ||
} | ||
|
@@ -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; | ||
} | ||
} |
There was a problem hiding this comment.
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, theurl
parameter is (a URL for an LNURL endpoint, and not the encoded LNURL...... strings).There was a problem hiding this comment.
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