diff --git a/libs/Cargo.lock b/libs/Cargo.lock index 1aa746ecf..c67b5e2a0 100644 --- a/libs/Cargo.lock +++ b/libs/Cargo.lock @@ -1142,6 +1142,7 @@ dependencies = [ "prost 0.11.9", "rand 0.8.5", "rcgen", + "regex", "reqwest", "ring", "rustls-pemfile", @@ -2791,9 +2792,9 @@ dependencies = [ [[package]] name = "regex" -version = "1.9.3" +version = "1.9.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "81bc1d4caf89fac26a70747fe603c130093b53c773888797a6329091246d651a" +checksum = "12de2eff854e5fa4b1295edd650e227e9d8fb0c9e90b12e7f36d6a6811791a29" dependencies = [ "aho-corasick", "memchr", @@ -2803,9 +2804,9 @@ dependencies = [ [[package]] name = "regex-automata" -version = "0.3.6" +version = "0.3.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fed1ceff11a1dddaee50c9dc8e4938bd106e9d89ae372f192311e7da498e3b69" +checksum = "49530408a136e16e5b486e883fbb6ba058e8e4e8ae6621a77b048b314336e629" dependencies = [ "aho-corasick", "memchr", @@ -2814,9 +2815,9 @@ dependencies = [ [[package]] name = "regex-syntax" -version = "0.7.4" +version = "0.7.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e5ea92a5b6195c6ef2a0295ea818b312502c6fc94dde986c5553242e18fd4ce2" +checksum = "dbb5fb1acd8a1a18b3dd5be62d25485eb770e05afb408a9627d14d451bae12da" [[package]] name = "reqwest" diff --git a/libs/gl-client/Cargo.toml b/libs/gl-client/Cargo.toml index 9a226d77e..7966ad68a 100644 --- a/libs/gl-client/Cargo.toml +++ b/libs/gl-client/Cargo.toml @@ -51,6 +51,7 @@ rand = "0.8.5" uuid = {version = "1.4.0", features=["serde"]} time = { version = "0.3", features = ["macros", "serde-well-known"] } serde_with = { version = "2", features = ["time_0_3"] } +regex = "1.9.4" [build-dependencies] tonic-build = "^0.8" diff --git a/libs/gl-client/src/lnurl/pay/mod.rs b/libs/gl-client/src/lnurl/pay/mod.rs index c0f0a1f50..46aa02855 100644 --- a/libs/gl-client/src/lnurl/pay/mod.rs +++ b/libs/gl-client/src/lnurl/pay/mod.rs @@ -7,32 +7,26 @@ use crate::lnurl::{ use anyhow::{anyhow, ensure, Result}; use lightning_invoice::{Invoice, InvoiceDescription}; use log::debug; +use regex::Regex; use reqwest::Url; use sha256; pub async fn resolve_lnurl_to_invoice( http_client: &T, - lnurl: &str, + lnurl_identifier: &str, amount_msats: u64, ) -> Result { - let url = parse_lnurl(lnurl)?; + let url = match is_lnurl(lnurl_identifier) { + true => parse_lnurl(lnurl_identifier)?, + false => parse_lightning_address(lnurl_identifier)?, + }; + + debug!("Domain: {}", Url::parse(&url).unwrap().host().unwrap()); let lnurl_pay_request_response: PayRequestResponse = http_client.get_pay_request_response(&url).await?; - if lnurl_pay_request_response.tag != "payRequest" { - return Err(anyhow!("Expected tag to say 'payRequest'")); - } - - ensure_amount_is_within_range(&lnurl_pay_request_response, amount_msats)?; - let description = extract_description(&lnurl_pay_request_response)?; - - debug!("Domain: {}", Url::parse(&url).unwrap().host().unwrap()); - debug!("Description: {}", description); - debug!( - "Accepted range (in millisatoshis): {} - {}", - lnurl_pay_request_response.min_sendable, lnurl_pay_request_response.max_sendable - ); + validate_pay_request_response(lnurl_identifier, &lnurl_pay_request_response, amount_msats)?; let callback_url = build_callback_url(&lnurl_pay_request_response, amount_msats)?; let callback_response: PayRequestCallbackResponse = http_client @@ -48,6 +42,60 @@ pub async fn resolve_lnurl_to_invoice( Ok(invoice.to_string()) } +fn is_lnurl(lnurl_identifier: &str) -> bool { + const LNURL_PREFIX: &str = "LNURL"; + lnurl_identifier + .trim() + .to_uppercase() + .starts_with(LNURL_PREFIX) +} + +pub fn validate_pay_request_response( + lnurl_identifier: &str, + lnurl_pay_request_response: &PayRequestResponse, + amount_msats: u64, +) -> Result<()> { + if lnurl_pay_request_response.tag != "payRequest" { + return Err(anyhow!("Expected tag to say 'payRequest'")); + } + ensure_amount_is_within_range(&lnurl_pay_request_response, amount_msats)?; + let description = extract_description(&lnurl_pay_request_response)?; + + debug!("Description: {}", description); + debug!( + "Accepted range (in millisatoshis): {} - {}", + lnurl_pay_request_response.min_sendable, lnurl_pay_request_response.max_sendable + ); + + if !is_lnurl(lnurl_identifier) { + let deserialized_metadata: Vec> = + serde_json::from_str(&lnurl_pay_request_response.metadata.to_owned()) + .map_err(|e| anyhow!("Failed to deserialize metadata: {}", e))?; + + let mut identifier = String::new(); + + let metadata_entry_types = ["text/email", "text/identifier"]; + + for metadata in deserialized_metadata { + let x = &*metadata[0].clone(); + if metadata_entry_types.contains(&x) { + identifier = String::from(metadata[1].clone()); + break; + } + } + + if identifier.is_empty() { + return Err(anyhow!("Could not find an entry of type ")); + } + + if identifier != lnurl_identifier { + return Err(anyhow!("The lightning address specified in the original request does not match what was found in the metadata array")); + } + } + + Ok(()) +} + // Validates the invoice on the pay request's callback response pub fn validate_invoice_from_callback_response( invoice: &Invoice, @@ -65,7 +113,9 @@ pub fn validate_invoice_from_callback_response( ensure!( description_hash == sha256::digest(metadata), - "description_hash does not match the hash of the metadata" + "description_hash {} does not match the hash of the metadata {}", + description_hash, + sha256::digest(metadata) ); Ok(()) @@ -81,7 +131,7 @@ fn extract_description(lnurl_pay_request_response: &PayRequestResponse) -> Resul serde_json::from_str(&serialized_metadata.to_owned()) .map_err(|e| anyhow!("Failed to deserialize metadata: {}", e))?; - for metadata in deserialized_metadata { + for metadata in deserialized_metadata.clone() { if metadata[0] == "text/plain" { description = metadata[1].clone(); } @@ -123,6 +173,47 @@ fn ensure_amount_is_within_range( Ok(()) } +//LUD-16: Paying to static internet identifiers. +pub fn parse_lightning_address(lightning_address: &str) -> Result { + let lightning_address_components: Vec<&str> = lightning_address.split("@").collect(); + + let username = lightning_address_components.get(0); + + if username.is_none() { + return Err(anyhow!("Could not parse username in lightning address")); + } + + let path = username.unwrap(); + + let domain = lightning_address_components.get(1); + + if domain.is_none() { + return Err(anyhow!("Could not parse domain in lightning address")); + } + + let domain = domain.unwrap(); + + if !validate_domain(domain) { + return Err(anyhow!("Domain {} is not a valid", domain)); + } + + let pay_request_url = ["https://", domain, "/.well-known/lnurlp/", path].concat(); + return Ok(pay_request_url); +} + +fn validate_domain(domain: &str) -> bool { + match Regex::new( + r"^(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z0-9][a-z0-9-]{0,61}[a-z0-9][.]?$", + ) { + Ok(regex) => { + return regex.is_match(domain); + } + Err(_e) => { + return false; + } + } +} + #[cfg(test)] mod tests { use crate::lnurl::models::MockLnUrlHttpClient; @@ -169,7 +260,42 @@ mod tests { let lnurl = "LNURL1DP68GURN8GHJ7CMFWP5X2UNSW4HXKTNRDAKJ7CTSDYHHVVF0D3H82UNV9UCSAXQZE2"; let amount = 100000; - let _ = resolve_lnurl_to_invoice(&mock_http_client, lnurl, amount).await; + let invoice = resolve_lnurl_to_invoice(&mock_http_client, &lnurl, amount).await; + assert!(invoice.is_ok()); + } + + #[tokio::test] + async fn test_lnurl_pay_with_lightning_address() { + let mut mock_http_client = MockLnUrlHttpClient::new(); + let lightning_address_username = "satoshi"; + let lightning_address_domain = "cipherpunk.com"; + let lnurl = format!( + "{}@{}", + lightning_address_username, lightning_address_domain + ); + + let lnurl_clone = lnurl.clone(); + mock_http_client.expect_get_pay_request_response().returning(move |url| { + let expected_url = format!("https://{}/.well-known/lnurlp/{}", lightning_address_domain, lightning_address_username); + assert_eq!(expected_url, url); + + let pay_request_json = format!("{{\"callback\": \"https://cipherpunk.com/lnurlp/api/v1/lnurl/cb/1\", \"maxSendable\": 100000, \"minSendable\": 100, \"tag\": \"payRequest\", \"metadata\": \"[[\\\"text/plain\\\", \\\"Start the CoinTrain\\\"], [\\\"text/identifier\\\", \\\"{}\\\"]]\" }}", lnurl_clone); + + let x: PayRequestResponse = serde_json::from_str(&pay_request_json).unwrap(); + convert_to_async_return_value(Ok(x)) + }); + + mock_http_client.expect_get_pay_request_callback_response().returning(|_url| { + let invoice = "lnbcrt1u1pj0ypx6sp5hzczugdw9eyw3fcsjkssux7awjlt68vpj7uhmen7sup0hdlrqxaqpp5gp5fm2sn5rua2jlzftkf5h22rxppwgszs7ncm73pmwhvjcttqp3qdy2tddjyar90p6z7urvv95kug3vyq39xarpwf6zqargv5syxmmfde28yctfdc396tpqtv38getcwshkjer9de6xjenfv4ezytpqyfekzar0wd5xjsrrd9cxsetjwp6ku6ewvdhk6gjat5xqyjw5qcqp29qxpqysgqujuf5zavazln2q9gks7nqwdgjypg2qlvv7aqwfmwg7xmjt8hy4hx2ctr5fcspjvmz9x5wvmur8vh6nkynsvateafm73zwg5hkf7xszsqajqwcf"; + let callback_response_json = format!("{{\"pr\":\"{}\",\"routes\":[]}}", invoice).to_string(); + let x = serde_json::from_str(&callback_response_json).unwrap(); + convert_to_async_return_value(Ok(x)) + }); + + let amount = 100000; + + let invoice = resolve_lnurl_to_invoice(&mock_http_client, &lnurl, amount).await; + assert(invoice.is_ok()); } #[tokio::test]