diff --git a/Cargo.toml b/Cargo.toml index cbae941..5fe0e10 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -60,6 +60,10 @@ required-features = ["client", "server"] name = "lud06" required-features = ["client", "server"] +[[test]] +name = "lud08" +required-features = ["client", "server"] + [[test]] name = "lud09" required-features = ["client", "server"] diff --git a/README.md b/README.md index 299d265..7fd8607 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ This library works as a toolkit so you can serve and make your LNURL requests wi - [LUD-05](https://github.com/lnurl/luds/blob/luds/05.md): 🆘 core 🆘 client 🆘 server 🆘 tests - [LUD-06](https://github.com/lnurl/luds/blob/luds/06.md): ✅ core ✅ client ✅ server ✅ tests - [LUD-07](https://github.com/lnurl/luds/blob/luds/07.md): 🆘 core 🆘 client 🆘 server 🆘 tests -- [LUD-08](https://github.com/lnurl/luds/blob/luds/08.md): 🆘 core 🆘 client 🆘 server 🆘 tests +- [LUD-08](https://github.com/lnurl/luds/blob/luds/08.md): ✅ core ✅ client ✅ server ⚠️ tests - [LUD-09](https://github.com/lnurl/luds/blob/luds/09.md): ✅ core ✅ client ✅ server ✅ tests - [LUD-10](https://github.com/lnurl/luds/blob/luds/10.md): 🆘 core 🆘 client 🆘 server 🆘 tests - [LUD-11](https://github.com/lnurl/luds/blob/luds/11.md): ✅ core ✅ client ✅ server ✅ tests diff --git a/src/client.rs b/src/client.rs index f6a43ec..6b70c05 100644 --- a/src/client.rs +++ b/src/client.rs @@ -6,9 +6,15 @@ impl Client { /// /// Returns errors on network or deserialization failures. pub async fn query(&self, s: &str) -> Result { - let url = crate::resolve(s)?; - let client = &self.0; + + let url = match crate::resolve(s)? { + crate::Resolved::Url(url) => url, + crate::Resolved::Withdraw(_, core) => { + return Ok(Response::Withdraw(Withdraw { client, core })) + } + }; + let response = client.get(url).send().await.map_err(|_| "request failed")?; let bytes = response.bytes().await.map_err(|_| "body failed")?; diff --git a/src/core.rs b/src/core.rs index fb50632..c621196 100644 --- a/src/core.rs +++ b/src/core.rs @@ -2,11 +2,16 @@ pub mod channel; pub mod pay; pub mod withdraw; +pub enum Resolved { + Url(url::Url), + Withdraw(url::Url, withdraw::client::Response), +} + /// # Errors /// /// Returns error in case `s` cannot be understood. -pub fn resolve(s: &str) -> Result { - if s.starts_with("lnurl1") || s.starts_with("LNURL1") { +pub fn resolve(s: &str) -> Result { + let url = if s.starts_with("lnurl1") || s.starts_with("LNURL1") { resolve_bech32(s) } else if s.starts_with("lnurl") || s.starts_with("keyauth") { resolve_scheme(s) @@ -14,7 +19,19 @@ pub fn resolve(s: &str) -> Result { resolve_address(s) } else { Err("unknown") - } + }?; + + let tag = url + .query_pairs() + .find_map(|(k, v)| (k == "tag").then_some(v)); + + Ok(match tag.as_deref() { + Some(withdraw::TAG) => match url.as_str().parse::() { + Ok(w) => Resolved::Withdraw(url, w), + Err(_) => Resolved::Url(url), + }, + _ => Resolved::Url(url), + }) } fn resolve_bech32(s: &str) -> Result { @@ -106,50 +123,85 @@ mod tests { #[test] fn resolve_bech32() { let input = "lnurl1dp68gurn8ghj7argv4ex2tnfwvhkumelwv7hqmm0dc6p3ztw"; - assert_eq!( - super::resolve(input).unwrap().to_string(), - "https://there.is/no?s=poon" - ); + let super::Resolved::Url(url) = super::resolve(input).unwrap() else { + panic!("expected resolved url"); + }; + + assert_eq!(url.as_str(), "https://there.is/no?s=poon"); let input = "LNURL1DP68GURN8GHJ7ARGV4EX2TNFWVHKUMELWV7HQMM0DC6P3ZTW"; - assert_eq!( - super::resolve(input).unwrap().to_string(), - "https://there.is/no?s=poon" - ); + let super::Resolved::Url(url) = super::resolve(input).unwrap() else { + panic!("expected resolved url"); + }; + + assert_eq!(url.as_str(), "https://there.is/no?s=poon"); } #[test] fn resolve_address() { - assert_eq!( - super::resolve("no-spoon@there.is").unwrap().to_string(), - "https://there.is/.well-known/lnurlp/no-spoon" - ); + let super::Resolved::Url(url) = super::resolve("no-spoon@there.is").unwrap() else { + panic!("expected resolved url"); + }; + + assert_eq!(url.as_str(), "https://there.is/.well-known/lnurlp/no-spoon"); } #[test] fn resolve_schemes() { let input = "lnurlc://there.is/no?s=poon"; - assert_eq!( - super::resolve(input).unwrap().to_string(), - "https://there.is/no?s=poon" - ); + let super::Resolved::Url(url) = super::resolve(input).unwrap() else { + panic!("expected resolved url"); + }; + + assert_eq!(url.as_str(), "https://there.is/no?s=poon"); let input = "lnurlw://there.is/no?s=poon"; - assert_eq!( - super::resolve(input).unwrap().to_string(), - "https://there.is/no?s=poon" - ); + let super::Resolved::Url(url) = super::resolve(input).unwrap() else { + panic!("expected resolved url"); + }; + + assert_eq!(url.as_str(), "https://there.is/no?s=poon"); let input = "lnurlp://there.is/no?s=poon"; - assert_eq!( - super::resolve(input).unwrap().to_string(), - "https://there.is/no?s=poon" - ); + let super::Resolved::Url(url) = super::resolve(input).unwrap() else { + panic!("expected resolved url"); + }; + + assert_eq!(url.as_str(), "https://there.is/no?s=poon"); let input = "keyauth://there.is/no?s=poon"; + let super::Resolved::Url(url) = super::resolve(input).unwrap() else { + panic!("expected resolved url"); + }; + + assert_eq!(url.as_str(), "https://there.is/no?s=poon"); + } + + #[test] + fn resolve_fast_withdraw() { + let input = "lnurlw://there.is/no\ + ?s=poon\ + &tag=withdrawRequest\ + &k1=caum\ + &minWithdrawable=314\ + &maxWithdrawable=315\ + &defaultDescription=descrical\ + &callback=https://call.back"; + + let super::Resolved::Withdraw(url, _) = super::resolve(input).unwrap() else { + panic!("expected resolved url"); + }; + assert_eq!( - super::resolve(input).unwrap().to_string(), - "https://there.is/no?s=poon" + url.as_str(), + "https://there.is/no\ + ?s=poon\ + &tag=withdrawRequest\ + &k1=caum\ + &minWithdrawable=314\ + &maxWithdrawable=315\ + &defaultDescription=descrical\ + &callback=https://call.back" ); } } diff --git a/src/core/withdraw/client.rs b/src/core/withdraw/client.rs index 85b38d0..a49f773 100644 --- a/src/core/withdraw/client.rs +++ b/src/core/withdraw/client.rs @@ -23,6 +23,22 @@ impl TryFrom<&[u8]> for Response { } } +impl std::str::FromStr for Response { + type Err = &'static str; + + fn from_str(s: &str) -> Result { + let d: de::Response = serde_urlencoded::from_str(s).map_err(|_| "deserialize failed")?; + + Ok(Response { + k1: d.k1, + callback: d.callback, + description: d.default_description, + min: d.min_withdrawable, + max: d.max_withdrawable, + }) + } +} + impl Response { #[must_use] pub fn callback<'a>(&'a self, pr: &'a str) -> CallbackQuery { @@ -98,7 +114,7 @@ mod de { #[cfg(test)] mod tests { #[test] - fn response_parse() { + fn response_bytes_parse() { let input = r#"{ "callback": "https://yuri?o=callback", "defaultDescription": "verde com bolinhas", @@ -116,6 +132,26 @@ mod tests { assert_eq!(parsed.min, 314); } + #[test] + fn response_string_parse() { + let input = "lnurlw://there.is/no\ + ?s=poon\ + &tag=withdrawRequest\ + &k1=caum\ + &minWithdrawable=314\ + &maxWithdrawable=315\ + &defaultDescription=descricao\ + &callback=https://call.back"; + + let parsed: super::Response = input.parse().expect("parse"); + + assert_eq!(parsed.callback.to_string(), "https://call.back/"); + assert_eq!(parsed.description, "descricao"); + assert_eq!(parsed.k1, "caum"); + assert_eq!(parsed.min, 314); + assert_eq!(parsed.max, 315); + } + #[test] fn callback_query_render() { let input = r#"{ diff --git a/src/core/withdraw/server.rs b/src/core/withdraw/server.rs index daabd2d..5ab0024 100644 --- a/src/core/withdraw/server.rs +++ b/src/core/withdraw/server.rs @@ -23,6 +23,21 @@ impl TryFrom for Vec { } } +impl std::fmt::Display for Response { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let s = serde_urlencoded::to_string(ser::Response { + tag: super::TAG, + callback: &self.callback, + default_description: &self.description, + min_withdrawable: self.min, + max_withdrawable: self.max, + k1: &self.k1, + }) + .map_err(|_| std::fmt::Error)?; + f.write_str(&s) + } +} + pub struct CallbackQuery { pub k1: String, pub pr: String, diff --git a/src/lib.rs b/src/lib.rs index 33d85dd..5f45892 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,7 +1,7 @@ #![cfg_attr(all(doc, docsrs), feature(doc_auto_cfg))] mod core; -pub use core::{channel, pay, resolve, withdraw, Response}; +pub use core::{channel, pay, resolve, withdraw, Resolved, Response}; #[cfg(feature = "client")] pub mod client; diff --git a/tests/lud01.rs b/tests/lud01.rs index 2e69c2d..ddd703a 100644 --- a/tests/lud01.rs +++ b/tests/lud01.rs @@ -3,11 +3,16 @@ fn test() { let input = "LNURL1DP68GURN8GHJ7UM9WFMXJCM99E3K7MF0V9CXJ0M385EKVCENXC6R2C35XVUKXEFCV5MKVV34X5EKZD3EV56NYD3HXQURZEPEXEJXXEPNXSCRVWFNV9NXZCN9XQ6XYEFHVGCXXCMYXYMNSERXFQ5FNS"; let decoded = "https://service.com/api?q=3fc3645b439ce8e7f2553a69e5267081d96dcd340693afabe04be7b0ccd178df"; - assert_eq!(lnurlkit::resolve(input).unwrap().to_string(), decoded); - assert_eq!( - lnurlkit::resolve(&input.to_lowercase()) - .unwrap() - .to_string(), - decoded - ); + let lnurlkit::Resolved::Url(url) = lnurlkit::resolve(input).expect("resolve") else { + panic!("wrong resolved"); + }; + + assert_eq!(url.as_str(), decoded); + + let lnurlkit::Resolved::Url(url) = lnurlkit::resolve(&input.to_lowercase()).expect("resolve") + else { + panic!("wrong resolved"); + }; + + assert_eq!(url.as_str(), decoded); } diff --git a/tests/lud08.rs b/tests/lud08.rs new file mode 100644 index 0000000..1b27426 --- /dev/null +++ b/tests/lud08.rs @@ -0,0 +1,80 @@ +#[tokio::test] +async fn test() { + let listener = tokio::net::TcpListener::bind("0.0.0.0:0") + .await + .expect("net"); + + let addr = listener.local_addr().expect("addr"); + + let callback = url::Url::parse(&format!("http://{addr}/lnurlw/callback")).expect("url"); + let callback2 = url::Url::parse(&format!("http://{addr}/lnurlw/callback")).expect("url"); + + let w = lnurlkit::withdraw::server::Response { + description: String::from("descricao"), + k1: String::from("caum"), + callback: callback.clone(), + min: 314, + max: 315, + }; + + let query_url_slow = format!("http://{addr}/lnurlw"); + let query_url_fast = format!("{query_url_slow}?{w}"); + + let router = lnurlkit::Server::default() + .withdraw_request( + move |()| { + let callback = callback.clone(); + async move { + Ok(lnurlkit::withdraw::server::Response { + description: String::from("outra-descricao"), + k1: String::from("cadois"), + callback, + min: 123, + max: 321, + }) + } + }, + |_: lnurlkit::withdraw::server::CallbackQuery| async { unimplemented!() }, + ) + .build(); + + tokio::spawn(async move { + axum::serve(listener, router).await.expect("serve"); + }); + + let client = lnurlkit::Client::default(); + + let lnurl = bech32::encode( + "lnurl", + bech32::ToBase32::to_base32(&query_url_slow), + bech32::Variant::Bech32, + ) + .expect("lnurl"); + + let queried = client.query(&lnurl).await.expect("query"); + let lnurlkit::client::Response::Withdraw(wr) = queried else { + panic!("not pay request"); + }; + + assert_eq!(wr.core.min, 123); + assert_eq!(wr.core.max, 321); + assert_eq!(&wr.core.description as &str, "outra-descricao"); + assert_eq!(wr.core.callback, callback2); + + let lnurl = bech32::encode( + "lnurl", + bech32::ToBase32::to_base32(&query_url_fast), + bech32::Variant::Bech32, + ) + .expect("lnurl"); + + let queried = client.query(&lnurl).await.expect("query"); + let lnurlkit::client::Response::Withdraw(wr) = queried else { + panic!("not pay request"); + }; + + assert_eq!(wr.core.min, 314); + assert_eq!(wr.core.max, 315); + assert_eq!(&wr.core.description as &str, "descricao"); + assert_eq!(wr.core.callback, callback2); +} diff --git a/tests/lud16.rs b/tests/lud16.rs index 7bf1164..f7a952c 100644 --- a/tests/lud16.rs +++ b/tests/lud16.rs @@ -44,7 +44,9 @@ async fn test() { let client = lnurlkit::Client::default(); let lnaddr = format!("nico@{addr}"); - let mut lnurl = lnurlkit::resolve(&lnaddr).expect("resolve"); + let lnurlkit::Resolved::Url(mut lnurl) = lnurlkit::resolve(&lnaddr).expect("resolve") else { + panic!("wrong resolved"); + }; lnurl.set_scheme("http").expect("scheme"); let bech32 = bech32::encode( @@ -62,7 +64,10 @@ async fn test() { assert_eq!(&pr.core.identifier.unwrap() as &str, "nico"); let lnaddr = format!("jorel@{addr}"); - let mut lnurl = lnurlkit::resolve(&lnaddr).expect("resolve"); + let lnurlkit::Resolved::Url(mut lnurl) = lnurlkit::resolve(&lnaddr).expect("resolve") else { + panic!("wrong resolved"); + }; + lnurl.set_scheme("http").expect("scheme"); let bech32 = bech32::encode(