fetch zap invoice

closes: https://github.com/damus-io/notedeck/issues/128

Signed-off-by: kernelkind <kernelkind@gmail.com>
This commit is contained in:
kernelkind
2025-03-13 16:06:21 -04:00
parent 1cf7e9e3d1
commit 38fb05475d
6 changed files with 417 additions and 1 deletions

1
Cargo.lock generated
View File

@@ -2848,6 +2848,7 @@ name = "notedeck"
version = "0.3.1"
dependencies = [
"base32",
"bech32",
"bincode",
"dirs",
"eframe",

View File

@@ -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 }

View File

@@ -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 {

View File

@@ -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::{

View File

@@ -0,0 +1 @@
mod networking;

View File

@@ -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"));
}
}