Skip to content

Commit

Permalink
Make lnurl a client (...) and add pay request support and example
Browse files Browse the repository at this point in the history
  • Loading branch information
lsunsi committed Dec 2, 2023
1 parent c6fed76 commit cce685f
Show file tree
Hide file tree
Showing 6 changed files with 144 additions and 153 deletions.
5 changes: 5 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,13 @@ readme = "README.md"
base64 = { version = "0.21.5", features = ["std"], default-features = false }
bech32 = { version = "0.9.0", default-features = false }
miniserde = { version = "0.1.0", default-features = false }
reqwest = { version = "0.11.0", default-features = false }
url = { version = "2.5.0", default-features = false }

[dev-dependencies]
reqwest = { version = "0.11.0", features = ["rustls-tls-webpki-roots"], default-features = false }
tokio = { version = "1.0.0", features = ["rt", "macros"], default-features = false }

[lints.rust]
warnings = "deny"

Expand Down
21 changes: 21 additions & 0 deletions examples/pay_request.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
use std::println;

#[tokio::main(flavor = "current_thread")]
async fn main() {
let client = lnurl_kit::Lnurl::default();

let queried = client
.query("lnurl1dp68gurn8ghj7cnfwpsjuctswqhjuam9d3kz66mwdamkutmvde6hymrs9a4k2mn4cdry4t")
.await
.expect("query");

println!("{queried:?}");

let lnurl_kit::Query::PayRequest(pr) = queried else {
panic!("not pay request");
};

let invoice = pr.callback(123000).await.expect("callback");

println!("{invoice}");
}
71 changes: 33 additions & 38 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,17 @@ mod pay_request;
mod serde;
mod withdrawal_request;

pub struct Lnurl(url::Url);
pub use pay_request::PayRequest;

impl std::str::FromStr for Lnurl {
type Err = &'static str;
#[derive(Clone, Default)]
pub struct Lnurl(reqwest::Client);

fn from_str(s: &str) -> Result<Self, Self::Err> {
impl Lnurl {
/// # Errors
///
/// Will return error in case `s` is not a valid lnurl,
/// when request or parsing fails, basically anything that goes bad.
pub async fn query(&self, s: &str) -> Result<Query, &'static str> {
let Ok((hrp, data, _)) = bech32::decode(s) else {
return Err("bech32 decode failed");
};
Expand All @@ -29,49 +34,39 @@ impl std::str::FromStr for Lnurl {
return Err("bech32 text is not a url");
};

Ok(Lnurl(url))
let response = self.0.get(url).send().await.map_err(|_| "request failed")?;
let body = response.text().await.map_err(|_| "body failed")?;
let query = build(&body, &self.0).map_err(|_| "parse failed")?;

Ok(query)
}
}

pub enum Query {
#[derive(Debug)]
pub enum Query<'a> {
ChannelRequest(channel_request::ChannelRequest),
WithdrawalRequest(withdrawal_request::WithdrawalRequest),
PayRequest(pay_request::PayRequest),
}

#[derive(miniserde::Deserialize)]
struct QueryTag {
tag: String,
PayRequest(pay_request::PayRequest<'a>),
}

impl std::str::FromStr for Query {
type Err = &'static str;

fn from_str(s: &str) -> Result<Self, Self::Err> {
let tag = miniserde::json::from_str::<QueryTag>(s).map_err(|_| "deserialize tag failed")?;

if tag.tag == channel_request::TAG {
let cr = miniserde::json::from_str(s).map_err(|_| "deserialize data failed")?;
Ok(Query::ChannelRequest(cr))
} else if tag.tag == withdrawal_request::TAG {
let wr = miniserde::json::from_str(s).map_err(|_| "deserialize data failed")?;
Ok(Query::WithdrawalRequest(wr))
} else if tag.tag == pay_request::TAG {
let pr = s.parse().map_err(|_| "deserialize data failed")?;
Ok(Query::PayRequest(pr))
} else {
Err("unknown tag")
}
fn build<'a>(s: &str, client: &'a reqwest::Client) -> Result<Query<'a>, &'static str> {
#[derive(miniserde::Deserialize)]
struct Tag {
tag: String,
}
}

#[cfg(test)]
mod tests {
#[test]
fn lnurl_try_from() {
let input = "LNURL1DP68GURN8GHJ7UM9WFMXJCM99E3K7MF0V9CXJ0M385EKVCENXC6R2C35XVUKXEFCV5MKVV34X5EKZD3EV56NYD3HXQURZEPEXEJXXEPNXSCRVWFNV9NXZCN9XQ6XYEFHVGCXXCMYXYMNSERXFQ5FNS";
let lnurl: super::Lnurl = input.parse().expect("parse");
let tag = miniserde::json::from_str::<Tag>(s).map_err(|_| "deserialize tag failed")?;

assert_eq!(lnurl.0.to_string(), "https://service.com/api?q=3fc3645b439ce8e7f2553a69e5267081d96dcd340693afabe04be7b0ccd178df");
if tag.tag == channel_request::TAG {
let cr = miniserde::json::from_str(s).map_err(|_| "deserialize data failed")?;
Ok(Query::ChannelRequest(cr))
} else if tag.tag == withdrawal_request::TAG {
let wr = miniserde::json::from_str(s).map_err(|_| "deserialize data failed")?;
Ok(Query::WithdrawalRequest(wr))
} else if tag.tag == pay_request::TAG {
let pr = pay_request::build(s, client).map_err(|_| "deserialize data failed")?;
Ok(Query::PayRequest(pr))
} else {
Err("unknown tag")
}
}
196 changes: 83 additions & 113 deletions src/pay_request.rs
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
pub const TAG: &str = "payRequest";

#[derive(Debug, Clone)]
pub struct PayRequest {
pub struct PayRequest<'a> {
client: &'a reqwest::Client,
callback: crate::serde::Url,
pub short_description: String,
pub long_description: Option<String>,
Expand All @@ -11,129 +12,98 @@ pub struct PayRequest {
pub max: u64,
}

impl std::str::FromStr for PayRequest {
type Err = &'static str;

fn from_str(s: &str) -> Result<Self, Self::Err> {
use base64::{prelude::BASE64_STANDARD, Engine};
use miniserde::{json::Value, Deserialize};

#[derive(Deserialize)]
struct Deserialized {
metadata: String,
callback: crate::serde::Url,
#[serde(rename = "minSendable")]
min_sendable: u64,
#[serde(rename = "maxSendable")]
max_sendable: u64,
}

let d: Deserialized = miniserde::json::from_str(s).map_err(|_| "deserialize failed")?;
let metadata = miniserde::json::from_str::<Vec<(String, Value)>>(&d.metadata)
.map_err(|_| "deserialize metadata failed")?;

let short_description = metadata
.iter()
.find_map(|(k, v)| (k == "text/plain").then_some(v))
.and_then(|v| match v {
Value::String(s) => Some(String::from(s)),
_ => None,
})
.ok_or("short description failed")?;

let long_description = metadata
.iter()
.find_map(|(k, v)| (k == "text/long-desc").then_some(v))
.and_then(|v| match v {
Value::String(s) => Some(String::from(s)),
_ => None,
});

let jpeg = metadata
.iter()
.find_map(|(k, v)| (k == "image/jpeg;base64").then_some(v))
.and_then(|v| match v {
Value::String(s) => BASE64_STANDARD.decode(s).ok(),
_ => None,
});
pub(crate) fn build<'a>(
s: &str,
client: &'a reqwest::Client,
) -> Result<PayRequest<'a>, &'static str> {
use base64::{prelude::BASE64_STANDARD, Engine};
use miniserde::{json::Value, Deserialize};

#[derive(Deserialize)]
struct Deserialized {
metadata: String,
callback: crate::serde::Url,
#[serde(rename = "minSendable")]
min_sendable: u64,
#[serde(rename = "maxSendable")]
max_sendable: u64,
}

let png = metadata
.iter()
.find_map(|(k, v)| (k == "image/png;base64").then_some(v))
.and_then(|v| match v {
Value::String(s) => BASE64_STANDARD.decode(s).ok(),
_ => None,
});
let d: Deserialized = miniserde::json::from_str(s).map_err(|_| "deserialize failed")?;
let metadata = miniserde::json::from_str::<Vec<(String, Value)>>(&d.metadata)
.map_err(|_| "deserialize metadata failed")?;

Ok(PayRequest {
callback: d.callback,
min: d.min_sendable,
max: d.max_sendable,
short_description,
long_description,
jpeg,
png,
let short_description = metadata
.iter()
.find_map(|(k, v)| (k == "text/plain").then_some(v))
.and_then(|v| match v {
Value::String(s) => Some(String::from(s)),
_ => None,
})
}
.ok_or("short description failed")?;

let long_description = metadata
.iter()
.find_map(|(k, v)| (k == "text/long-desc").then_some(v))
.and_then(|v| match v {
Value::String(s) => Some(String::from(s)),
_ => None,
});

let jpeg = metadata
.iter()
.find_map(|(k, v)| (k == "image/jpeg;base64").then_some(v))
.and_then(|v| match v {
Value::String(s) => BASE64_STANDARD.decode(s).ok(),
_ => None,
});

let png = metadata
.iter()
.find_map(|(k, v)| (k == "image/png;base64").then_some(v))
.and_then(|v| match v {
Value::String(s) => BASE64_STANDARD.decode(s).ok(),
_ => None,
});

Ok(PayRequest {
client,
callback: d.callback,
min: d.min_sendable,
max: d.max_sendable,
short_description,
long_description,
jpeg,
png,
})
}

impl PayRequest {
pub fn callback(mut self, millisatoshis: u64) -> url::Url {
impl PayRequest<'_> {
/// # Errors
///
/// Returns errors on network or deserialization failures.
pub async fn callback(mut self, millisatoshis: u64) -> Result<String, &'static str> {
#[derive(miniserde::Deserialize)]
struct Deserialized {
pr: String,
}

self.callback
.0
.query_pairs_mut()
.append_pair("amount", &millisatoshis.to_string());

self.callback.0
}
}

#[cfg(test)]
mod tests {
#[test]
fn test() {
let input = r#"
{
"callback": "https://bipa.app/callback?q=1",
"maxSendable": 200000,
"minSendable": 100000,
"metadata": "[[\"text/plain\", \"olá\"]]"
}
"#;

let cr = input.parse::<super::PayRequest>().expect("parse");

assert_eq!(cr.callback.0.to_string(), "https://bipa.app/callback?q=1");
assert_eq!(cr.max, 200000);
assert_eq!(cr.min, 100000);
assert_eq!(cr.short_description, "olá");

assert!(cr.long_description.is_none());
assert!(cr.jpeg.is_none());
assert!(cr.png.is_none());

assert_eq!(
cr.callback(123).to_string(),
"https://bipa.app/callback?q=1&amount=123"
);

let input = r#"
{
"callback": "https://bipa.app/callback?q=1",
"maxSendable": 200000,
"minSendable": 100000,
"metadata": "[[\"text/plain\", \"olá\"],[\"text/long-desc\", \"oie\"],[\"image/png;base64\", \"YWJj\"],[\"image/jpeg;base64\", \"cXdlcnR5\"]]"
}
"#;
let response = self
.client
.get(self.callback.0)
.send()
.await
.map_err(|_| "request failed")?;

let cr = input.parse::<super::PayRequest>().expect("parse");
let body = response.text().await.map_err(|_| "body failed")?;
let response =
miniserde::json::from_str::<Deserialized>(&body).map_err(|_| "deserialize failed")?;

assert_eq!(cr.callback.0.to_string(), "https://bipa.app/callback?q=1");
assert_eq!(cr.max, 200000);
assert_eq!(cr.min, 100000);
assert_eq!(cr.short_description, "olá");
assert_eq!(cr.long_description.unwrap(), "oie");
assert_eq!(cr.jpeg.unwrap(), b"qwerty");
assert_eq!(cr.png.unwrap(), b"abc");
Ok(response.pr)
}
}
2 changes: 1 addition & 1 deletion src/serde.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ use miniserde::{de::Visitor, make_place, Deserialize, Error, Result};
make_place!(Place);

#[derive(Debug, Clone)]
pub struct Url(pub url::Url);
pub(crate) struct Url(pub(crate) url::Url);

impl Visitor for Place<Url> {
fn string(&mut self, s: &str) -> Result<()> {
Expand Down
2 changes: 1 addition & 1 deletion src/withdrawal_request.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
pub const TAG: &str = "withdrawalRequest";

#[derive(miniserde::Deserialize)]
#[derive(Debug, miniserde::Deserialize)]
pub struct WithdrawalRequest {
callback: crate::serde::Url,
k1: String,
Expand Down

0 comments on commit cce685f

Please sign in to comment.