From 6cbaff379cd3b29024bc22889f71a3345e3d766d Mon Sep 17 00:00:00 2001 From: Lucas Sunsi Abreu Date: Mon, 4 Dec 2023 17:28:35 -0300 Subject: [PATCH] feat(withdraw): add client/server and a test for withdraw (lud03) --- Cargo.toml | 4 ++ README.md | 2 +- src/client.rs | 82 +++++++++++++++--------------- src/core/withdraw_request.rs | 80 ++++++++++++++++++++++++++++-- src/server.rs | 96 +++++++++++++++++++++++++++++------- tests/lud03.rs | 69 ++++++++++++++++++++++++++ tests/lud06.rs | 2 +- tests/lud09.rs | 2 +- tests/lud11.rs | 2 +- tests/lud12.rs | 2 +- 10 files changed, 275 insertions(+), 66 deletions(-) create mode 100644 tests/lud03.rs diff --git a/Cargo.toml b/Cargo.toml index a6ca6af..8787370 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -46,6 +46,10 @@ perf = "deny" style = "deny" suspicious = "deny" +[[test]] +name = "lud03" +required-features = ["client", "server"] + [[test]] name = "lud06" required-features = ["client", "server"] diff --git a/README.md b/README.md index 3e2b5b4..193e859 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ This library works as a toolkit so you can serve and make your LNURL requests wi - [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-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 - [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 diff --git a/src/client.rs b/src/client.rs index 8eb73bb..5534c0c 100644 --- a/src/client.rs +++ b/src/client.rs @@ -1,3 +1,5 @@ +use crate::core; + #[derive(Clone, Default)] pub struct Client(reqwest::Client); @@ -6,83 +8,72 @@ impl Client { /// /// Returns errors on network or deserialization failures. pub async fn query(&self, s: &str) -> Result { - let url = crate::core::resolve(s)?; + let url = core::resolve(s)?; let client = &self.0; let response = client.get(url).send().await.map_err(|_| "request failed")?; let text = response.text().await.map_err(|_| "body failed")?; - text.parse::() + text.parse::() .map_err(|_| "parse failed") .map(|query| match query { - crate::core::Query::PayRequest(core) => { - Query::PayRequest(PayRequest { client, core }) - } - crate::core::Query::ChannelRequest(core) => { + core::Query::ChannelRequest(core) => { Query::ChannelRequest(ChannelRequest { client, core }) } - crate::core::Query::WithdrawRequest(core) => { + core::Query::WithdrawRequest(core) => { Query::WithdrawRequest(WithdrawRequest { client, core }) } + core::Query::PayRequest(core) => Query::PayRequest(PayRequest { client, core }), }) } } #[derive(Clone, Debug)] pub enum Query<'a> { - PayRequest(PayRequest<'a>), ChannelRequest(ChannelRequest<'a>), WithdrawRequest(WithdrawRequest<'a>), + PayRequest(PayRequest<'a>), } #[derive(Clone, Debug)] -pub struct PayRequest<'a> { +pub struct ChannelRequest<'a> { client: &'a reqwest::Client, - pub core: crate::core::pay_request::PayRequest, + pub core: core::channel_request::ChannelRequest, } #[derive(Clone, Debug)] -pub struct ChannelRequest<'a> { +pub struct WithdrawRequest<'a> { client: &'a reqwest::Client, - pub core: crate::core::channel_request::ChannelRequest, + pub core: core::withdraw_request::WithdrawRequest, } #[derive(Clone, Debug)] -pub struct WithdrawRequest<'a> { +pub struct PayRequest<'a> { client: &'a reqwest::Client, - pub core: crate::core::withdraw_request::WithdrawRequest, + pub core: core::pay_request::PayRequest, } -impl PayRequest<'_> { +impl ChannelRequest<'_> { /// # Errors /// /// Returns errors on network or deserialization failures. - pub async fn callback( - self, - comment: &str, - millisatoshis: u64, - ) -> Result { - let callback = self.core.callback(comment, millisatoshis); + pub async fn callback_accept(self, remoteid: &str, private: bool) -> Result<(), &'static str> { + let callback = self.core.callback_accept(remoteid, private); - let response = self - .client + self.client .get(callback) .send() .await .map_err(|_| "request failed")?; - let text = response.text().await.map_err(|_| "body failed")?; - - text.parse().map_err(|_| "parse failed") + Ok(()) } -} -impl ChannelRequest<'_> { /// # Errors /// /// Returns errors on network or deserialization failures. - pub async fn callback_accept(self, remoteid: &str, private: bool) -> Result<(), &'static str> { - let callback = self.core.callback_accept(remoteid, private); + pub async fn callback_cancel(self, remoteid: &str) -> Result<(), &'static str> { + let callback = self.core.callback_cancel(remoteid); self.client .get(callback) @@ -92,36 +83,49 @@ impl ChannelRequest<'_> { Ok(()) } +} +impl WithdrawRequest<'_> { /// # Errors /// /// Returns errors on network or deserialization failures. - pub async fn callback_cancel(self, remoteid: &str) -> Result<(), &'static str> { - let callback = self.core.callback_cancel(remoteid); + pub async fn callback( + self, + pr: &str, + ) -> Result { + let callback = self.core.callback(pr); - self.client + let response = self + .client .get(callback) .send() .await .map_err(|_| "request failed")?; - Ok(()) + let text = response.text().await.map_err(|_| "body failed")?; + text.parse().map_err(|_| "parse failed") } } -impl WithdrawRequest<'_> { +impl PayRequest<'_> { /// # Errors /// /// Returns errors on network or deserialization failures. - pub async fn callback(self, pr: &str) -> Result<(), &'static str> { - let callback = self.core.callback(pr); + pub async fn callback( + self, + comment: &str, + millisatoshis: u64, + ) -> Result { + let callback = self.core.callback(comment, millisatoshis); - self.client + let response = self + .client .get(callback) .send() .await .map_err(|_| "request failed")?; - Ok(()) + let text = response.text().await.map_err(|_| "body failed")?; + text.parse().map_err(|_| "parse failed") } } diff --git a/src/core/withdraw_request.rs b/src/core/withdraw_request.rs index 6312856..9bdf01d 100644 --- a/src/core/withdraw_request.rs +++ b/src/core/withdraw_request.rs @@ -2,8 +2,8 @@ pub const TAG: &str = "withdrawRequest"; #[derive(Clone, Debug)] pub struct WithdrawRequest { - k1: String, - callback: url::Url, + pub k1: String, + pub callback: url::Url, pub description: String, pub min: u64, pub max: u64, @@ -13,7 +13,7 @@ impl std::str::FromStr for WithdrawRequest { type Err = &'static str; fn from_str(s: &str) -> Result { - let d: serde::QueryResponse = + let d: de::QueryResponse = miniserde::json::from_str(s).map_err(|_| "deserialize failed")?; Ok(WithdrawRequest { @@ -26,6 +26,19 @@ impl std::str::FromStr for WithdrawRequest { } } +impl std::fmt::Display for WithdrawRequest { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(&miniserde::json::to_string(&ser::QueryResponse { + tag: TAG, + callback: crate::serde::Url(self.callback.clone()), + default_description: &self.description, + min_withdrawable: self.min, + max_withdrawable: self.max, + k1: &self.k1, + })) + } +} + impl WithdrawRequest { /// # Errors /// @@ -40,7 +53,66 @@ impl WithdrawRequest { } } -mod serde { +#[derive(Debug)] +pub enum CallbackResponse { + Error(String), + Ok, +} + +impl std::str::FromStr for CallbackResponse { + type Err = &'static str; + + fn from_str(s: &str) -> Result { + let map = miniserde::json::from_str::>(s) + .map_err(|_| "bad json")?; + + match map.get("status").map(|s| s as &str) { + Some("OK") => Ok(CallbackResponse::Ok), + Some("ERROR") => Ok(CallbackResponse::Error( + map.get("reason").cloned().unwrap_or_default(), + )), + _ => Err("bad status field"), + } + } +} + +impl std::fmt::Display for CallbackResponse { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let mut map = std::collections::BTreeMap::new(); + + match self { + CallbackResponse::Error(reason) => { + map.insert("status", "ERROR"); + map.insert("reason", reason); + } + CallbackResponse::Ok => { + map.insert("status", "OK"); + } + } + + f.write_str(&miniserde::json::to_string(&map)) + } +} + +mod ser { + use crate::serde::Url; + use miniserde::Serialize; + + #[derive(Serialize)] + pub(super) struct QueryResponse<'a> { + pub tag: &'static str, + pub k1: &'a str, + pub callback: Url, + #[serde(rename = "defaultDescription")] + pub default_description: &'a str, + #[serde(rename = "minWithdrawable")] + pub min_withdrawable: u64, + #[serde(rename = "maxWithdrawable")] + pub max_withdrawable: u64, + } +} + +mod de { use crate::serde::Url; use miniserde::Deserialize; diff --git a/src/server.rs b/src/server.rs index c756676..5ccad0d 100644 --- a/src/server.rs +++ b/src/server.rs @@ -1,58 +1,110 @@ +use crate::core; use axum::{extract::RawQuery, http::StatusCode, routing::get, Router}; use std::future::Future; -pub struct Server { - pay_request_query: PQ, - pay_request_callback: PC, +pub struct Server { + withdraw_query: WQ, + withdraw_callback: WC, + pay_query: PQ, + pay_callback: PC, } impl Default for Server< - unimplemented::Handler0, - unimplemented::Handler1<(u64, Option), crate::core::pay_request::CallbackResponse>, + unimplemented::Handler0, + unimplemented::Handler1, + unimplemented::Handler0, + unimplemented::Handler1<(u64, Option), core::pay_request::CallbackResponse>, > { fn default() -> Self { Server { - pay_request_query: unimplemented::handler0, - pay_request_callback: unimplemented::handler1, + withdraw_query: unimplemented::handler0, + withdraw_callback: unimplemented::handler1, + pay_query: unimplemented::handler0, + pay_callback: unimplemented::handler1, } } } -impl Server { +impl Server { + pub fn withdraw_request( + self, + withdraw_query: WQ2, + withdraw_callback: WC2, + ) -> Server { + Server { + pay_query: self.pay_query, + pay_callback: self.pay_callback, + withdraw_query, + withdraw_callback, + } + } + pub fn pay_request( self, - pay_request_query: PQ2, - pay_request_callback: PC2, - ) -> Server { + pay_query: PQ2, + pay_callback: PC2, + ) -> Server { Server { - pay_request_query, - pay_request_callback, + pay_query, + pay_callback, + withdraw_query: self.withdraw_query, + withdraw_callback: self.withdraw_callback, } } } -impl Server +impl Server where + WQ: 'static + Send + Clone + Fn() -> WQFut, + WQFut: Send + Future>, + + WC: 'static + Send + Clone + Fn(String) -> WCFut, + WCFut: Send + Future>, + PQ: 'static + Send + Clone + Fn() -> PQFut, - PQFut: Send + Future>, + PQFut: Send + Future>, + PC: 'static + Send + Clone + Fn((u64, Option)) -> PCFut, - PCFut: Send + Future>, + PCFut: Send + Future>, { pub fn build(self) -> Router<()> { Router::new() + .route( + "/lnurlw", + get(move || { + let pq = self.withdraw_query.clone(); + async move { pq().await.map(|a| a.to_string()) } + }), + ) + .route( + "/lnurlw/callback", + get(move |RawQuery(q): RawQuery| { + let wc = self.withdraw_callback.clone(); + async move { + let q = q.ok_or(StatusCode::BAD_REQUEST)?; + let qs = q + .split('&') + .filter_map(|s| s.split_once('=')) + .collect::>(); + + let pr = qs.get("pr").ok_or(StatusCode::BAD_REQUEST)?; + wc(String::from(*pr)).await.map(|a| a.to_string()) + } + }), + ) .route( "/lnurlp", get(move || { - let pq = self.pay_request_query.clone(); + let pq = self.pay_query.clone(); async move { pq().await.map(|a| a.to_string()) } }), ) .route( "/lnurlp/callback", get(move |RawQuery(q): RawQuery| { - let pc = self.pay_request_callback.clone(); + let pc = self.pay_callback.clone(); async move { let q = q.ok_or(StatusCode::BAD_REQUEST)?; let qs = q @@ -104,3 +156,11 @@ mod unimplemented { Unimplemented(PhantomData) } } + +#[cfg(test)] +mod tests { + #[test] + fn default_builds() { + drop(super::Server::default().build()); + } +} diff --git a/tests/lud03.rs b/tests/lud03.rs new file mode 100644 index 0000000..0c9f0cf --- /dev/null +++ b/tests/lud03.rs @@ -0,0 +1,69 @@ +#[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 query_url = format!("http://{addr}/lnurlw"); + let callback_url = url::Url::parse(&format!("http://{addr}/lnurlw/callback")).expect("url"); + + let router = lnurlkit::server::Server::default() + .withdraw_request( + move || { + let callback = callback_url.clone(); + async { + Ok(lnurlkit::core::withdraw_request::WithdrawRequest { + description: String::from("descricao"), + k1: String::from("caum"), + callback, + min: 314, + max: 315, + }) + } + }, + |pr| async move { + Ok(if pr == "pierre" { + lnurlkit::core::withdraw_request::CallbackResponse::Ok + } else { + lnurlkit::core::withdraw_request::CallbackResponse::Error(pr) + }) + }, + ) + .build(); + + tokio::spawn(async move { + axum::serve(listener, router).await.expect("serve"); + }); + + let client = lnurlkit::client::Client::default(); + + let lnurl = bech32::encode( + "lnurl", + bech32::ToBase32::to_base32(&query_url), + bech32::Variant::Bech32, + ) + .expect("lnurl"); + + let queried = client.query(&lnurl).await.expect("query"); + let lnurlkit::client::Query::WithdrawRequest(wr) = queried else { + panic!("not pay request"); + }; + + assert_eq!(wr.core.min, 314); + assert_eq!(wr.core.max, 315); + assert_eq!(wr.core.description, "descricao"); + + let response = wr.clone().callback("pierre").await.expect("callback"); + assert!(matches!( + response, + lnurlkit::core::withdraw_request::CallbackResponse::Ok + )); + + let response = wr.callback("pierrado").await.expect("callback"); + assert!(matches!( + response, + lnurlkit::core::withdraw_request::CallbackResponse::Error(r) if r == "pierrado" + )); +} diff --git a/tests/lud06.rs b/tests/lud06.rs index c880b49..e0d750e 100644 --- a/tests/lud06.rs +++ b/tests/lud06.rs @@ -26,7 +26,7 @@ async fn test() { }) } }, - move |(amount, _)| async move { + |(amount, _)| async move { Ok(lnurlkit::core::pay_request::CallbackResponse { pr: format!("pierre:{amount}"), disposable: false, diff --git a/tests/lud09.rs b/tests/lud09.rs index fa4a33b..ff96f89 100644 --- a/tests/lud09.rs +++ b/tests/lud09.rs @@ -26,7 +26,7 @@ async fn test() { }) } }, - move |(amount, comment): (u64, Option)| async move { + |(amount, comment): (u64, Option)| async move { Ok(lnurlkit::core::pay_request::CallbackResponse { pr: String::new(), disposable: false, diff --git a/tests/lud11.rs b/tests/lud11.rs index 9fd2bcd..28c05de 100644 --- a/tests/lud11.rs +++ b/tests/lud11.rs @@ -26,7 +26,7 @@ async fn test() { }) } }, - move |(amount, _)| async move { + |(amount, _)| async move { Ok(lnurlkit::core::pay_request::CallbackResponse { pr: String::new(), disposable: amount % 2 == 0, diff --git a/tests/lud12.rs b/tests/lud12.rs index bd533ec..da375de 100644 --- a/tests/lud12.rs +++ b/tests/lud12.rs @@ -26,7 +26,7 @@ async fn test() { }) } }, - move |(_, comment)| async move { + |(_, comment)| async move { Ok(lnurlkit::core::pay_request::CallbackResponse { pr: format!("pierre:{comment:?}"), disposable: false,