From 909971bb52044833be848b208e6236a64e473de8 Mon Sep 17 00:00:00 2001 From: Lucas Sunsi Abreu Date: Wed, 6 Dec 2023 07:29:28 -0300 Subject: [PATCH] feat(pay): add support for lud16 (pay to identifier) --- Cargo.toml | 4 ++ README.md | 4 +- src/core/pay.rs | 106 ++++++++++++++++++++++++++++++++++++++++++++++++ src/server.rs | 58 ++++++++++++++------------ tests/lud02.rs | 2 +- tests/lud03.rs | 2 +- tests/lud06.rs | 4 +- tests/lud09.rs | 4 +- tests/lud11.rs | 4 +- tests/lud12.rs | 4 +- tests/lud16.rs | 81 ++++++++++++++++++++++++++++++++++++ 11 files changed, 239 insertions(+), 34 deletions(-) create mode 100644 tests/lud16.rs diff --git a/Cargo.toml b/Cargo.toml index 13db2f9..9a9b730 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -69,3 +69,7 @@ required-features = ["client", "server"] [[test]] name = "lud12" required-features = ["client", "server"] + +[[test]] +name = "lud16" +required-features = ["client", "server"] diff --git a/README.md b/README.md index 454687b..299d265 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ This library works as a toolkit so you can serve and make your LNURL requests wi ## Current support -- [LUD-01](https://github.com/lnurl/luds/blob/luds/01.md): ✅ core ✅ client ✅ server ⚠️ tests +- [LUD-01](https://github.com/lnurl/luds/blob/luds/01.md): ✅ core ✅ client ✅ server ✅ tests - [LUD-02](https://github.com/lnurl/luds/blob/luds/02.md): ✅ core ✅ client ✅ server ⚠️ tests - [LUD-03](https://github.com/lnurl/luds/blob/luds/03.md): ✅ core ✅ client ✅ server ⚠️ tests - [LUD-04](https://github.com/lnurl/luds/blob/luds/04.md): 🆘 core 🆘 client 🆘 server 🆘 tests @@ -24,7 +24,7 @@ This library works as a toolkit so you can serve and make your LNURL requests wi - [LUD-13](https://github.com/lnurl/luds/blob/luds/13.md): 🆘 core 🆘 client 🆘 server 🆘 tests - [LUD-14](https://github.com/lnurl/luds/blob/luds/14.md): 🆘 core 🆘 client 🆘 server 🆘 tests - [LUD-15](https://github.com/lnurl/luds/blob/luds/15.md): 🆘 core 🆘 client 🆘 server 🆘 tests -- [LUD-16](https://github.com/lnurl/luds/blob/luds/16.md): ✅ core ⚠️ client 🆘 server 🆘 tests +- [LUD-16](https://github.com/lnurl/luds/blob/luds/16.md): ✅ core ✅ client ✅ server ✅ tests - [LUD-17](https://github.com/lnurl/luds/blob/luds/17.md): ✅ core ⚠️ client ⚠️ server ⚠️ tests - [LUD-18](https://github.com/lnurl/luds/blob/luds/18.md): 🆘 core 🆘 client 🆘 server 🆘 tests - [LUD-19](https://github.com/lnurl/luds/blob/luds/19.md): 🆘 core 🆘 client 🆘 server 🆘 tests diff --git a/src/core/pay.rs b/src/core/pay.rs index e6a827d..d67fc17 100644 --- a/src/core/pay.rs +++ b/src/core/pay.rs @@ -5,6 +5,8 @@ pub struct Query { pub callback: url::Url, pub short_description: String, pub long_description: Option, + pub identifier: Option, + pub email: Option, pub jpeg: Option>, pub png: Option>, pub comment_size: u64, @@ -59,6 +61,22 @@ impl std::str::FromStr for Query { _ => None, }); + let identifier = metadata + .iter() + .find_map(|(k, v)| (k == "text/identifier").then_some(v)) + .and_then(|v| match v { + Value::String(s) => Some(String::from(s)), + _ => None, + }); + + let email = metadata + .iter() + .find_map(|(k, v)| (k == "text/email").then_some(v)) + .and_then(|v| match v { + Value::String(s) => Some(String::from(s)), + _ => None, + }); + Ok(Query { callback: p.callback.0.into_owned(), min: p.min_sendable, @@ -66,6 +84,8 @@ impl std::str::FromStr for Query { short_description, long_description, comment_size, + identifier, + email, jpeg, png, }) @@ -88,6 +108,10 @@ impl std::fmt::Display for Query { self.png .as_ref() .map(|s| ("image/png;base64", BASE64_STANDARD.encode(s))), + self.identifier + .as_ref() + .map(|s| ("text/identifier", s.clone())), + self.email.as_ref().map(|s| ("text/email", s.clone())), ] .into_iter() .flatten() @@ -270,6 +294,8 @@ mod tests { assert!(parsed.long_description.is_none()); assert!(parsed.jpeg.is_none()); assert!(parsed.png.is_none()); + assert!(parsed.identifier.is_none()); + assert!(parsed.email.is_none()); } #[test] @@ -322,6 +348,36 @@ mod tests { assert_eq!(parsed.png.unwrap(), b"fotobrutal"); } + #[test] + fn query_parse_identifier() { + let input = r#" + { + "callback": "https://yuri?o=callback", + "metadata": "[[\"text/plain\", \"boneco do steve magal\"],[\"text/identifier\", \"steve@magal.brutal\"]]", + "maxSendable": 315, + "minSendable": 314 + } + "#; + + let parsed = input.parse::().expect("parse"); + assert_eq!(parsed.identifier.unwrap(), "steve@magal.brutal"); + } + + #[test] + fn query_parse_email() { + let input = r#" + { + "callback": "https://yuri?o=callback", + "metadata": "[[\"text/plain\", \"boneco do steve magal\"],[\"text/email\", \"steve@magal.brutal\"]]", + "maxSendable": 315, + "minSendable": 314 + } + "#; + + let parsed = input.parse::().expect("parse"); + assert_eq!(parsed.email.unwrap(), "steve@magal.brutal"); + } + #[test] fn query_render_base() { let query = super::Query { @@ -333,6 +389,8 @@ mod tests { comment_size: 0, min: 314, max: 315, + identifier: None, + email: None, }; assert_eq!( @@ -352,6 +410,8 @@ mod tests { comment_size: 140, min: 314, max: 315, + identifier: None, + email: None, }; assert_eq!( @@ -371,6 +431,8 @@ mod tests { comment_size: 0, min: 314, max: 315, + identifier: None, + email: None, }; assert_eq!( @@ -390,6 +452,8 @@ mod tests { comment_size: 0, min: 314, max: 315, + identifier: None, + email: None, }; assert_eq!( @@ -398,6 +462,48 @@ mod tests { ); } + #[test] + fn query_render_identifier() { + let query = super::Query { + callback: url::Url::parse("https://yuri?o=callback").expect("url"), + short_description: String::from("boneco do steve magal"), + long_description: None, + jpeg: Some(b"imagembrutal".to_vec()), + png: Some(b"fotobrutal".to_vec()), + comment_size: 0, + min: 314, + max: 315, + identifier: Some(String::from("steve@magal.brutal")), + email: None, + }; + + assert_eq!( + query.to_string(), + r#"{"tag":"payRequest","metadata":"[[\"text/plain\",\"boneco do steve magal\"],[\"image/jpeg;base64\",\"aW1hZ2VtYnJ1dGFs\"],[\"image/png;base64\",\"Zm90b2JydXRhbA==\"],[\"text/identifier\",\"steve@magal.brutal\"]]","callback":"https://yuri/?o=callback","minSendable":314,"maxSendable":315,"commentAllowed":0}"# + ); + } + + #[test] + fn query_render_email() { + let query = super::Query { + callback: url::Url::parse("https://yuri?o=callback").expect("url"), + short_description: String::from("boneco do steve magal"), + long_description: None, + jpeg: Some(b"imagembrutal".to_vec()), + png: Some(b"fotobrutal".to_vec()), + comment_size: 0, + min: 314, + max: 315, + identifier: None, + email: Some(String::from("steve@magal.brutal")), + }; + + assert_eq!( + query.to_string(), + r#"{"tag":"payRequest","metadata":"[[\"text/plain\",\"boneco do steve magal\"],[\"image/jpeg;base64\",\"aW1hZ2VtYnJ1dGFs\"],[\"image/png;base64\",\"Zm90b2JydXRhbA==\"],[\"text/email\",\"steve@magal.brutal\"]]","callback":"https://yuri/?o=callback","minSendable":314,"maxSendable":315,"commentAllowed":0}"# + ); + } + #[test] fn callback() { let input = r#" diff --git a/src/server.rs b/src/server.rs index b8c4338..c3283ac 100644 --- a/src/server.rs +++ b/src/server.rs @@ -1,4 +1,4 @@ -use axum::{extract::RawQuery, http::StatusCode, routing::get, Router}; +use axum::{extract::Path, extract::RawQuery, http::StatusCode, routing::get, Router}; use std::future::Future; pub struct Server { @@ -13,29 +13,29 @@ pub struct Server { impl Default for Server< // Channel Request - unimplemented::Query, - unimplemented::Callback< + unimplemented::Handler<(), crate::core::channel::Query>, + unimplemented::Handler< (String, String, crate::core::channel::CallbackAction), crate::core::channel::CallbackResponse, >, // Pay Request - unimplemented::Query, - unimplemented::Callback<(u64, Option), crate::core::pay::CallbackResponse>, + unimplemented::Handler, crate::core::pay::Query>, + unimplemented::Handler<(u64, Option), crate::core::pay::CallbackResponse>, // Withdraw Request - unimplemented::Query, - unimplemented::Callback<(String, String), crate::core::withdraw::CallbackResponse>, + unimplemented::Handler<(), crate::core::withdraw::Query>, + unimplemented::Handler<(String, String), crate::core::withdraw::CallbackResponse>, > { fn default() -> Self { Server { - channel_query: unimplemented::query, - channel_callback: unimplemented::callback, + channel_query: unimplemented::handler, + channel_callback: unimplemented::handler, - pay_query: unimplemented::query, - pay_callback: unimplemented::callback, + pay_query: unimplemented::handler, + pay_callback: unimplemented::handler, - withdraw_query: unimplemented::query, - withdraw_callback: unimplemented::callback, + withdraw_query: unimplemented::handler, + withdraw_callback: unimplemented::handler, } } } @@ -90,7 +90,7 @@ impl Server { impl Server where - CQ: 'static + Send + Clone + Fn() -> CQFut, + CQ: 'static + Send + Clone + Fn(()) -> CQFut, CQFut: Send + Future>, CC: 'static @@ -99,25 +99,26 @@ where + Fn((String, String, crate::core::channel::CallbackAction)) -> CCFut, CCFut: Send + Future>, - PQ: 'static + Send + Clone + Fn() -> PQFut, + PQ: 'static + Send + Clone + Fn(Option) -> PQFut, PQFut: Send + Future>, PC: 'static + Send + Clone + Fn((u64, Option)) -> PCFut, PCFut: Send + Future>, - WQ: 'static + Send + Clone + Fn() -> WQFut, + WQ: 'static + Send + Clone + Fn(()) -> WQFut, WQFut: Send + Future>, WC: 'static + Send + Clone + Fn((String, String)) -> WCFut, WCFut: Send + Future>, { + #[allow(clippy::too_many_lines)] pub fn build(self) -> Router<()> { Router::new() .route( "/lnurlc", get(move || { let cq = self.channel_query.clone(); - async move { cq().await.map(|a| a.to_string()) } + async move { cq(()).await.map(|a| a.to_string()) } }), ) .route( @@ -155,11 +156,21 @@ where } }), ) + .route( + "/.well-known/lnurlp/:identifier", + get({ + let pq = self.pay_query.clone(); + move |Path(identifier): Path| { + let pq = pq.clone(); + async move { pq(Some(identifier)).await.map(|a| a.to_string()) } + } + }), + ) .route( "/lnurlp", get(move || { let pq = self.pay_query.clone(); - async move { pq().await.map(|a| a.to_string()) } + async move { pq(None).await.map(|a| a.to_string()) } }), ) .route( @@ -188,7 +199,7 @@ where "/lnurlw", get(move || { let wq = self.withdraw_query.clone(); - async move { wq().await.map(|a| a.to_string()) } + async move { wq(()).await.map(|a| a.to_string()) } }), ) .route( @@ -222,13 +233,8 @@ mod unimplemented { task::{Context, Poll}, }; - pub(super) type Query = fn() -> Unimplemented; - pub(super) fn query() -> Unimplemented { - Unimplemented(PhantomData) - } - - pub(super) type Callback = fn(Param) -> Unimplemented; - pub(super) fn callback(_: T1) -> Unimplemented { + pub(super) type Handler = fn(Param) -> Unimplemented; + pub(super) fn handler(_: Param) -> Unimplemented { Unimplemented(PhantomData) } diff --git a/tests/lud02.rs b/tests/lud02.rs index ed542a9..fb83cb2 100644 --- a/tests/lud02.rs +++ b/tests/lud02.rs @@ -11,7 +11,7 @@ async fn test() { let router = lnurlkit::Server::default() .channel_request( - move || { + move |()| { let callback = callback_url.clone(); async { Ok(lnurlkit::channel::Query { diff --git a/tests/lud03.rs b/tests/lud03.rs index 63a0381..bdcba22 100644 --- a/tests/lud03.rs +++ b/tests/lud03.rs @@ -11,7 +11,7 @@ async fn test() { let router = lnurlkit::Server::default() .withdraw_request( - move || { + move |()| { let callback = callback_url.clone(); async { Ok(lnurlkit::withdraw::Query { diff --git a/tests/lud06.rs b/tests/lud06.rs index 565093f..50af8c1 100644 --- a/tests/lud06.rs +++ b/tests/lud06.rs @@ -11,7 +11,7 @@ async fn test() { let router = lnurlkit::Server::default() .pay_request( - move || { + move |_| { let callback = callback_url.clone(); async { Ok(lnurlkit::pay::Query { @@ -23,6 +23,8 @@ async fn test() { comment_size: 0, min: 314, max: 315, + identifier: None, + email: None, }) } }, diff --git a/tests/lud09.rs b/tests/lud09.rs index 9c3ca5c..5fac6ae 100644 --- a/tests/lud09.rs +++ b/tests/lud09.rs @@ -11,7 +11,7 @@ async fn test() { let router = lnurlkit::Server::default() .pay_request( - move || { + move |_| { let callback = callback_url.clone(); async { Ok(lnurlkit::pay::Query { @@ -23,6 +23,8 @@ async fn test() { comment_size: 0, min: 314, max: 315, + identifier: None, + email: None, }) } }, diff --git a/tests/lud11.rs b/tests/lud11.rs index 2d328ba..e33b77a 100644 --- a/tests/lud11.rs +++ b/tests/lud11.rs @@ -11,7 +11,7 @@ async fn test() { let router = lnurlkit::Server::default() .pay_request( - move || { + move |_| { let callback = callback_url.clone(); async { Ok(lnurlkit::pay::Query { @@ -23,6 +23,8 @@ async fn test() { comment_size: 0, min: 314, max: 315, + identifier: None, + email: None, }) } }, diff --git a/tests/lud12.rs b/tests/lud12.rs index 4a916c4..27253a8 100644 --- a/tests/lud12.rs +++ b/tests/lud12.rs @@ -11,7 +11,7 @@ async fn test() { let router = lnurlkit::Server::default() .pay_request( - move || { + move |_| { let callback = callback_url.clone(); async { Ok(lnurlkit::pay::Query { @@ -23,6 +23,8 @@ async fn test() { comment_size: 140, min: 314, max: 315, + identifier: None, + email: None, }) } }, diff --git a/tests/lud16.rs b/tests/lud16.rs new file mode 100644 index 0000000..66c5242 --- /dev/null +++ b/tests/lud16.rs @@ -0,0 +1,81 @@ +#[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::Url::parse(&format!("http://{addr}/lnurlp/callback")).expect("url"); + + let router = lnurlkit::Server::default() + .pay_request( + move |identifier: Option| { + let callback = callback_url.clone(); + async { + Ok(lnurlkit::pay::Query { + callback, + short_description: String::from("today i become death"), + long_description: Some(String::from("the destroyer of worlds")), + jpeg: None, + png: None, + comment_size: 0, + min: 314, + max: 315, + identifier: identifier.clone().filter(|i| i.starts_with('n')), + email: identifier.filter(|i| i.starts_with('j')), + }) + } + }, + |(amount, _)| async move { + Ok(lnurlkit::pay::CallbackResponse { + pr: format!("pierre:{amount}"), + disposable: false, + success_action: None, + }) + }, + ) + .build(); + + tokio::spawn(async move { + axum::serve(listener, router).await.expect("serve"); + }); + + let client = lnurlkit::Client::default(); + + let lnaddr = format!("nico@{addr}"); + let mut lnurl = lnurlkit::resolve(&lnaddr).expect("resolve"); + lnurl.set_scheme("http").expect("scheme"); + + let bech32 = bech32::encode( + "lnurl", + bech32::ToBase32::to_base32(&lnurl.as_ref()), + bech32::Variant::Bech32, + ) + .expect("bech32"); + + let queried = client.query(&bech32).await.expect("query"); + let lnurlkit::client::Query::Pay(pr) = queried else { + panic!("not pay request"); + }; + + assert_eq!(pr.core.identifier.unwrap(), "nico"); + + let lnaddr = format!("jorel@{addr}"); + let mut lnurl = lnurlkit::resolve(&lnaddr).expect("resolve"); + lnurl.set_scheme("http").expect("scheme"); + + let bech32 = bech32::encode( + "lnurl", + bech32::ToBase32::to_base32(&lnurl.as_ref()), + bech32::Variant::Bech32, + ) + .expect("bech32"); + + let queried = client.query(&bech32).await.expect("query"); + let lnurlkit::client::Query::Pay(pr) = queried else { + panic!("not pay request"); + }; + + assert_eq!(pr.core.email.unwrap(), "jorel"); +}