commit 38fb05475da068ab12775993b9261a6bd5409dea
parent 1cf7e9e3d1e31acbeba76e0e610a307376b6334b
Author: kernelkind <kernelkind@gmail.com>
Date: Thu, 13 Mar 2025 16:06:21 -0400
fetch zap invoice
closes: https://github.com/damus-io/notedeck/issues/128
Signed-off-by: kernelkind <kernelkind@gmail.com>
Diffstat:
6 files changed, 418 insertions(+), 1 deletion(-)
diff --git a/Cargo.lock b/Cargo.lock
@@ -2848,6 +2848,7 @@ name = "notedeck"
version = "0.3.1"
dependencies = [
"base32",
+ "bech32",
"bincode",
"dirs",
"eframe",
diff --git a/crates/notedeck/Cargo.toml b/crates/notedeck/Cargo.toml
@@ -35,9 +35,11 @@ tokenator = { workspace = true }
profiling = { workspace = true }
nwc = { workspace = true }
tokio = { workspace = true }
+bech32 = { workspace = true }
[dev-dependencies]
tempfile = { workspace = true }
+tokio = { workspace = true }
[target.'cfg(target_os = "android")'.dependencies]
jni = { workspace = true }
diff --git a/crates/notedeck/src/error.rs b/crates/notedeck/src/error.rs
@@ -23,6 +23,23 @@ pub enum Error {
#[error("generic error: {0}")]
Generic(String),
+
+ #[error("zaps error: {0}")]
+ Zap(#[from] ZapError),
+}
+
+#[derive(Debug, thiserror::Error, Clone)]
+pub enum ZapError {
+ #[error("invalid lud16")]
+ InvalidLud16(String),
+ #[error("invalid endpoint response")]
+ EndpointError(String),
+ #[error("bech encoding/decoding error")]
+ Bech(String),
+ #[error("serialization/deserialization problem")]
+ Serialization(String),
+ #[error("nwc error")]
+ NWC(String),
}
impl From<String> for Error {
diff --git a/crates/notedeck/src/lib.rs b/crates/notedeck/src/lib.rs
@@ -26,12 +26,13 @@ mod unknowns;
mod urls;
mod user_account;
mod wallet;
+mod zaps;
pub use accounts::{AccountData, Accounts, AccountsAction, AddAccountAction, SwitchAccountAction};
pub use app::{App, Notedeck};
pub use args::Args;
pub use context::AppContext;
-pub use error::{Error, FilterError};
+pub use error::{Error, FilterError, ZapError};
pub use filter::{FilterState, FilterStates, UnifiedSubscription};
pub use fonts::NamedFontFamily;
pub use imgcache::{
diff --git a/crates/notedeck/src/zaps/mod.rs b/crates/notedeck/src/zaps/mod.rs
@@ -0,0 +1 @@
+mod networking;
+\ No newline at end of file
diff --git a/crates/notedeck/src/zaps/networking.rs b/crates/notedeck/src/zaps/networking.rs
@@ -0,0 +1,394 @@
+use crate::ZapError;
+use enostr::{NoteId, Pubkey};
+use nostrdb::NoteBuilder;
+use poll_promise::Promise;
+use serde::Deserialize;
+use tokio::task::JoinError;
+use url::Url;
+
+#[allow(dead_code)]
+pub struct FetchedInvoice {
+ pub invoice: String,
+ pub request_noteid: NoteId, // note id of kind 9734 request
+}
+
+pub type FetchingInvoice = Promise<Result<Result<FetchedInvoice, ZapError>, JoinError>>;
+
+async fn fetch_pay_req_async(url: &Url) -> Result<LNUrlPayRequest, ZapError> {
+ let (sender, promise) = Promise::new();
+
+ let on_done = move |response: Result<ehttp::Response, String>| {
+ let handle = response.map_err(ZapError::EndpointError).and_then(|resp| {
+ if !resp.ok {
+ return Err(ZapError::EndpointError(format!(
+ "bad http response: {}",
+ resp.status_text
+ )));
+ }
+
+ serde_json::from_slice(&resp.bytes).map_err(|e| ZapError::Serialization(e.to_string()))
+ });
+
+ sender.send(handle);
+ };
+
+ let request = ehttp::Request::get(url);
+ ehttp::fetch(request, on_done);
+ tokio::task::block_in_place(|| promise.block_and_take())
+}
+
+async fn fetch_pay_req_from_lud16(lud16: &str) -> Result<LNUrlPayRequest, ZapError> {
+ let url = match generate_endpoint_url(lud16) {
+ Ok(url) => url,
+ Err(e) => return Err(e),
+ };
+
+ fetch_pay_req_async(&url).await
+}
+
+static HRP_LNURL: bech32::Hrp = bech32::Hrp::parse_unchecked("lnurl");
+
+fn lud16_to_lnurl(lud16: &str) -> Result<String, ZapError> {
+ let endpoint_url = generate_endpoint_url(lud16)?;
+
+ let url_str = endpoint_url.to_string();
+ let data = url_str.as_bytes();
+
+ bech32::encode::<bech32::Bech32>(HRP_LNURL, data).map_err(|e| ZapError::Bech(e.to_string()))
+}
+
+fn make_kind_9734<'a>(
+ lnurl: &str,
+ msats: u64,
+ sender_nsec: &[u8; 32],
+ relays: Vec<String>,
+ recipient: &Pubkey,
+ event_id: Option<&NoteId>,
+) -> nostrdb::Note<'a> {
+ let mut builder = NoteBuilder::new().kind(9734);
+
+ builder = builder.start_tag().tag_str("relays");
+
+ for relay in relays {
+ builder = builder.tag_str(&relay)
+ }
+
+ builder = builder
+ .start_tag()
+ .tag_str("amount")
+ .tag_str(&msats.to_string());
+
+ builder = builder.start_tag().tag_str("lnurl").tag_str(lnurl);
+
+ builder = builder.start_tag().tag_str("p").tag_str(&recipient.hex());
+
+ if let Some(id) = event_id {
+ builder = builder.start_tag().tag_str("e").tag_str(&id.hex());
+ }
+
+ builder.sign(sender_nsec).build().expect("note")
+}
+
+#[allow(dead_code)]
+#[derive(Debug, Deserialize)]
+pub struct LNUrlPayRequest {
+ #[serde(rename = "allowsNostr")]
+ allow_nostr: bool,
+
+ #[serde(rename = "nostrPubkey")]
+ nostr_pubkey: String,
+
+ #[serde(rename = "callback")]
+ callback_url: String,
+
+ #[serde(rename = "minSendable")]
+ min_sendable: u64,
+
+ #[serde(rename = "maxSendable")]
+ max_sendable: u64,
+}
+
+#[derive(Debug, Deserialize)]
+struct LNInvoice {
+ #[serde(rename = "pr")]
+ invoice: String,
+}
+
+fn endpoint_query_for_invoice<'a>(
+ endpoint_base_url: &'a mut Url,
+ msats: u64,
+ lnurl: &str,
+ note: nostrdb::Note,
+) -> Result<&'a Url, ZapError> {
+ let nostr = note
+ .json()
+ .map_err(|e| ZapError::Serialization(format!("failed note to json: {e}")))?;
+
+ Ok(endpoint_base_url
+ .query_pairs_mut()
+ .append_pair("amount", &msats.to_string())
+ .append_pair("lnurl", lnurl)
+ .append_pair("nostr", &nostr)
+ .finish())
+}
+
+#[allow(dead_code)]
+pub fn fetch_invoice_lud16(
+ lud16: String,
+ msats: u64,
+ sender_nsec: [u8; 32],
+ event_id: Option<NoteId>,
+ relays: Vec<String>,
+) -> FetchingInvoice {
+ Promise::spawn_async(tokio::spawn(async move {
+ fetch_invoice_lud16_async(&lud16, msats, &sender_nsec, event_id.as_ref(), relays).await
+ }))
+}
+
+#[allow(dead_code)]
+pub fn fetch_invoice_lnurl(
+ lnurl: String,
+ msats: u64,
+ sender_nsec: [u8; 32],
+ event_id: Option<NoteId>,
+ relays: Vec<String>,
+) -> FetchingInvoice {
+ Promise::spawn_async(tokio::spawn(async move {
+ let pay_req = match fetch_pay_req_from_lnurl_async(&lnurl).await {
+ Ok(req) => req,
+ Err(e) => return Err(e),
+ };
+
+ fetch_invoice_lnurl_async(
+ &lnurl,
+ &pay_req,
+ msats,
+ &sender_nsec,
+ event_id.as_ref(),
+ relays,
+ )
+ .await
+ }))
+}
+
+fn convert_lnurl_to_endpoint_url(lnurl: &str) -> Result<Url, ZapError> {
+ let (_, data) = bech32::decode(lnurl).map_err(|e| ZapError::Bech(e.to_string()))?;
+
+ let url_str =
+ String::from_utf8(data).map_err(|e| ZapError::Bech(format!("string conversion: {e}")))?;
+
+ Url::parse(&url_str)
+ .map_err(|e| ZapError::EndpointError(format!("endpoint url from lnurl is invalid: {e}")))
+}
+
+async fn fetch_pay_req_from_lnurl_async(lnurl: &str) -> Result<LNUrlPayRequest, ZapError> {
+ let url = match convert_lnurl_to_endpoint_url(lnurl) {
+ Ok(u) => u,
+ Err(e) => return Err(e),
+ };
+
+ fetch_pay_req_async(&url).await
+}
+
+async fn fetch_invoice_lnurl_async(
+ lnurl: &str,
+ pay_req: &LNUrlPayRequest,
+ msats: u64,
+ sender_nsec: &[u8; 32],
+ event_id: Option<&NoteId>,
+ relays: Vec<String>,
+) -> Result<FetchedInvoice, ZapError> {
+ let recipient = Pubkey::from_hex(&pay_req.nostr_pubkey)
+ .map_err(|e| ZapError::EndpointError(format!("invalid pubkey hex from endpoint: {e}")))?;
+
+ let mut base_url = Url::parse(&pay_req.callback_url)
+ .map_err(|e| ZapError::EndpointError(format!("invalid callback url from endpoint: {e}")))?;
+
+ let (query, noteid) = {
+ let note = make_kind_9734(lnurl, msats, sender_nsec, relays, &recipient, event_id);
+ let noteid = NoteId::new(*note.id());
+ let query = endpoint_query_for_invoice(&mut base_url, msats, lnurl, note)?;
+ (query, noteid)
+ };
+
+ let res = fetch_invoice(query).await;
+ res.map(|i| FetchedInvoice {
+ invoice: i.invoice,
+ request_noteid: noteid,
+ })
+}
+
+async fn fetch_invoice_lud16_async(
+ lud16: &str,
+ msats: u64,
+ sender_nsec: &[u8; 32],
+ event_id: Option<&NoteId>,
+ relays: Vec<String>,
+) -> Result<FetchedInvoice, ZapError> {
+ let pay_req = fetch_pay_req_from_lud16(lud16).await?;
+
+ let lnurl = lud16_to_lnurl(lud16)?;
+
+ fetch_invoice_lnurl_async(&lnurl, &pay_req, msats, sender_nsec, event_id, relays).await
+}
+
+async fn fetch_invoice(req: &Url) -> Result<LNInvoice, ZapError> {
+ let request = ehttp::Request::get(req);
+ let (sender, promise) = Promise::new();
+ let on_done = move |response: Result<ehttp::Response, String>| {
+ let handle = response.map_err(ZapError::EndpointError).and_then(|resp| {
+ if !resp.ok {
+ return Err(ZapError::EndpointError(format!(
+ "invalid http response: {}",
+ resp.status_text
+ )));
+ }
+
+ serde_json::from_slice(&resp.bytes).map_err(|e| ZapError::Serialization(e.to_string()))
+ });
+
+ sender.send(handle);
+ };
+
+ ehttp::fetch(request, on_done);
+
+ tokio::task::block_in_place(|| promise.block_and_take())
+}
+
+fn generate_endpoint_url(lud16: &str) -> Result<Url, ZapError> {
+ let (user, domain, use_http) = {
+ let mut split = lud16.split('@');
+ let user = split
+ .next()
+ .ok_or_else(|| ZapError::InvalidLud16("lud16 did not have username".to_owned()))?;
+
+ let domain = split
+ .next()
+ .ok_or_else(|| ZapError::InvalidLud16("lud16 did not have domain".to_owned()))?;
+
+ let mut domain_split = domain.split('.');
+
+ let _ = domain_split
+ .next()
+ .ok_or_else(|| ZapError::InvalidLud16("lud16 domain is invalid".to_owned()))?;
+
+ let tld = domain_split.next().ok_or_else(|| {
+ ZapError::InvalidLud16("lud16 domain does not include tld".to_owned())
+ })?;
+
+ let use_http = tld == "onion";
+
+ (user, domain, use_http)
+ };
+
+ let url_str = format!(
+ "http{}://{domain}/.well-known/lnurlp/{user}",
+ if use_http { "" } else { "s" }
+ );
+
+ Url::parse(&url_str).map_err(|e| ZapError::EndpointError(e.to_string()))
+}
+
+#[cfg(test)]
+mod tests {
+ use enostr::FullKeypair;
+
+ use crate::zaps::networking::convert_lnurl_to_endpoint_url;
+
+ use super::{
+ fetch_invoice_lnurl, fetch_invoice_lud16, fetch_pay_req_from_lud16, lud16_to_lnurl,
+ };
+
+ #[ignore] // don't run this test automatically since it sends real http
+ #[tokio::test(flavor = "multi_thread")]
+ async fn test_get_pay_req() {
+ let lud16 = "jb55@sendsats.lol";
+
+ let maybe_res = fetch_pay_req_from_lud16(lud16).await;
+
+ assert!(maybe_res.is_ok());
+
+ let res = maybe_res.unwrap();
+
+ assert!(res.allow_nostr);
+ assert_eq!(
+ res.nostr_pubkey,
+ "9630f464cca6a5147aa8a35f0bcdd3ce485324e732fd39e09233b1d848238f31"
+ );
+ assert_eq!(res.callback_url, "https://sendsats.lol/@jb55");
+ assert_eq!(res.min_sendable, 1);
+ assert_eq!(res.max_sendable, 10000000000);
+ }
+
+ #[test]
+ fn test_lnurl() {
+ let lud16 = "jb55@sendsats.lol";
+
+ let maybe_lnurl = lud16_to_lnurl(lud16);
+ assert!(maybe_lnurl.is_ok());
+
+ let lnurl = maybe_lnurl.unwrap();
+ assert_eq!(
+ lnurl,
+ "lnurl1dp68gurn8ghj7um9dej8xct5wvhxcmmv9uh8wetvdskkkmn0wahz7mrww4excup0df3r2dg3mj444"
+ );
+ }
+
+ #[ignore] // don't run test automatically since it sends real http
+ #[test]
+ fn test_generate_invoice() {
+ let rt = tokio::runtime::Runtime::new().expect("Failed to create runtime");
+
+ let maybe_invoice = rt.block_on(async {
+ fetch_invoice_lud16(
+ "jb55@sendsats.lol".to_owned(),
+ 1000,
+ FullKeypair::generate().secret_key.to_secret_bytes(),
+ None,
+ vec!["wss://relay.damus.io".to_owned()],
+ )
+ .block_and_take()
+ });
+
+ assert!(maybe_invoice.is_ok());
+ let inner = maybe_invoice.unwrap();
+ assert!(inner.is_ok());
+ let invoice = inner.unwrap();
+ assert!(invoice.invoice.starts_with("lnbc"));
+ }
+
+ #[test]
+ fn test_convert_lnurl() {
+ let lnurl =
+ "lnurl1dp68gurn8ghj7um9dej8xct5wvhxcmmv9uh8wetvdskkkmn0wahz7mrww4excup0df3r2dg3mj444";
+
+ let maybe_url = convert_lnurl_to_endpoint_url(lnurl);
+ println!("{:?}", maybe_url);
+ assert!(maybe_url.is_ok());
+ }
+
+ #[ignore]
+ #[test]
+ fn test_generate_lnurl_invoice() {
+ let rt = tokio::runtime::Runtime::new().expect("Failed to create runtime");
+ let lnurl =
+ "lnurl1dp68gurn8ghj7um9dej8xct5wvhxcmmv9uh8wetvdskkkmn0wahz7mrww4excup0df3r2dg3mj444";
+ let kp = FullKeypair::generate();
+ let relay = "wss://relay.damus.io";
+
+ let maybe_invoice = rt.block_on(async {
+ fetch_invoice_lnurl(
+ lnurl.to_owned(),
+ 1000,
+ kp.secret_key.to_secret_bytes(),
+ None,
+ [relay.to_owned()].to_vec(),
+ )
+ .block_and_take()
+ });
+
+ assert!(maybe_invoice.is_ok());
+
+ assert!(maybe_invoice.unwrap().unwrap().invoice.starts_with("lnbc"));
+ }
+}