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

Open
wants to merge 1 commit into
base: main
Choose a base branch
from
Open
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
12 changes: 10 additions & 2 deletions src/dns_resolver.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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) })
}
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).

/// 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.


/// 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"),
}
}
}
}
37 changes: 36 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,38 @@ impl PaymentInstructions {
))
},
}
} else if let Some((_, data)) =
Copy link
Member

Choose a reason for hiding this comment

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

Isn't this supposed to support decoding any part of the string that contains lnurl* rather than only if the full string is lnurl*? I saw a checkout not long ago that used a QR code to display, eg https://a.website/to-visit?lnurl=lnurlasdfwerqwer so that users who just scan the QR code go to their website but if a wallet scans it it will resolve the lnurl.

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)
}
Expand Down