notedeck

One damus client to rule them all
git clone git://jb55.com/notedeck
Log | Files | Refs | README | LICENSE

commit cbf281dcc1f74899ff22b3eebe9f421f2a044fcb
parent fd2299f5f0b1917097f99364dca4371ef771fc51
Author: kernelkind <kernelkind@gmail.com>
Date:   Thu,  3 Apr 2025 18:55:14 -0400

introduce `Zaps`

Signed-off-by: kernelkind <kernelkind@gmail.com>

Diffstat:
Mcrates/notedeck/src/lib.rs | 4+++-
Acrates/notedeck/src/zaps/cache.rs | 638+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/notedeck/src/zaps/mod.rs | 1+
3 files changed, 642 insertions(+), 1 deletion(-)

diff --git a/crates/notedeck/src/lib.rs b/crates/notedeck/src/lib.rs @@ -54,7 +54,9 @@ pub use timecache::TimeCached; pub use unknowns::{get_unknown_note_ids, NoteRefsUnkIdAction, SingleUnkIdAction, UnknownIds}; pub use urls::{supported_mime_hosted_at_url, SupportedMimeType, UrlMimes}; pub use user_account::UserAccount; -pub use wallet::{GlobalWallet, Wallet, WalletError, WalletState, WalletType, WalletUIState}; +pub use wallet::{ + get_wallet_for_mut, GlobalWallet, Wallet, WalletError, WalletState, WalletType, WalletUIState, +}; // export libs pub use enostr; diff --git a/crates/notedeck/src/zaps/cache.rs b/crates/notedeck/src/zaps/cache.rs @@ -0,0 +1,638 @@ +use enostr::{NoteId, Pubkey}; +use nostrdb::{Ndb, Transaction}; +use nwc::nostr::nips::nip47::PayInvoiceResponse; +use poll_promise::Promise; +use tokio::task::JoinError; + +use crate::{get_wallet_for_mut, Accounts, GlobalWallet, ZapError}; + +use super::{ + networking::{fetch_invoice_lnurl, fetch_invoice_lud16, FetchedInvoice, FetchingInvoice}, + zap::Zap, +}; + +type ZapId = u32; + +#[allow(dead_code)] +pub struct Zaps { + next_id: ZapId, + zap_keys: hashbrown::HashMap<ZapKeyOwned, Vec<ZapId>>, + // using `ZapId`s like this allows us to be flexible. in the future, we can also do cheap queries for any zaps from only specific senders or targets: + // zap_targets: hashbrown::HashMap<ZapTargetOwned, Vec<ZapId>>, + // zap_senders: hashbrown::HashMap<Pubkey, Vec<ZapId>>, + zaps: std::collections::HashMap<ZapId, ZapState>, + in_flight: Vec<ZapPromise>, + events: Vec<EventResponse>, +} + +#[allow(dead_code)] +fn process_event( + id: ZapId, + event: ZapEvent, + accounts: &mut Accounts, + global_wallet: &mut GlobalWallet, + ndb: &Ndb, + txn: &Transaction, +) -> NextState { + match event { + ZapEvent::FetchInvoice { + zap_ctx, + sender_relays, + } => process_new_zap_event(zap_ctx, accounts, ndb, txn, sender_relays), + ZapEvent::SendNWC { + zap_ctx, + req_noteid, + invoice, + } => { + let Some(wallet) = get_wallet_for_mut(accounts, global_wallet, &zap_ctx.key.sender) + else { + return NextState::Event(EventResponse { + id, + event: Err(ZappingError::SenderNoWallet), + }); + }; + + let promise = wallet.pay_invoice(&invoice); + + let ctx = SendingNWCInvoiceContext { + request_noteid: req_noteid, + zap_ctx, + }; + NextState::Transition(ZapPromise::SendingNWCInvoice { ctx, promise }) + } + ZapEvent::EndpointConfirmed { + zap_ctx, + req_noteid, + } => NextState::Success { + id: zap_ctx.id, + zap: LocalConfirmedZap { + request_noteid: req_noteid, + sender: zap_ctx.key.sender, + target: zap_ctx.key.target, + msats: zap_ctx.msats, + }, + }, + } +} + +fn process_new_zap_event( + zap_ctx: ZapCtx, + accounts: &Accounts, + ndb: &Ndb, + txn: &Transaction, + sender_relays: Vec<String>, +) -> NextState { + let Some(full_kp) = accounts + .get_selected_account() + .or_else(|| accounts.find_account(zap_ctx.key.sender.bytes())) + .and_then(|u| u.key.to_full()) + else { + return NextState::Event(EventResponse { + id: zap_ctx.id, + event: Err(ZappingError::InvalidAccount), + }); + }; + + // TODO(kernelkind): support ZapTarget::Profile + let ZapTargetOwned::Note(note_target) = zap_ctx.key.target.clone() else { + return NextState::Event(EventResponse { + id: zap_ctx.id, + event: Err(ZappingError::UnsupportedOperation), + }); + }; + + let id = zap_ctx.id; + let promise = send_note_zap( + ndb, + txn, + note_target, + zap_ctx.msats, + &full_kp.secret_key.secret_bytes(), + sender_relays, + ) + .map(|promise| ZapPromise::FetchingInvoice { + ctx: zap_ctx, + promise, + }); + let Some(promise) = promise else { + return NextState::Event(EventResponse { + id, + event: Err(ZappingError::InvalidZapAddress), + }); + }; + + NextState::Transition(promise) +} + +#[allow(dead_code)] +fn send_note_zap( + ndb: &Ndb, + txn: &Transaction, + target: NoteZapTargetOwned, + msats: u64, + nsec: &[u8; 32], + relays: Vec<String>, +) -> Option<FetchingInvoice> { + let address = get_users_zap_endpoint(txn, ndb, &target.zap_recipient)?; + + let promise = match address { + ZapAddress::Lud16(s) => fetch_invoice_lud16(s, msats, *nsec, Some(target.note_id), relays), + ZapAddress::Lud06(s) => fetch_invoice_lnurl(s, msats, *nsec, Some(target.note_id), relays), + }; + Some(promise) +} + +enum ZapAddress { + Lud16(String), + Lud06(String), +} + +fn get_users_zap_endpoint(txn: &Transaction, ndb: &Ndb, receiver: &Pubkey) -> Option<ZapAddress> { + let profile = ndb + .get_profile_by_pubkey(txn, receiver.bytes()) + .ok()? + .record() + .profile()?; + + profile + .lud06() + .map(|l| ZapAddress::Lud06(l.to_string())) + .or(profile.lud16().map(|l| ZapAddress::Lud16(l.to_string()))) +} + +fn try_get_promise_response( + promises: &mut Vec<ZapPromise>, + promise_index: usize, // this index must be guarenteed to exist +) -> Option<PromiseResponse> { + if !is_promise_ready(&promises[promise_index]) { + return None; + } + + let promise = promises.remove(promise_index); + + match promise { + ZapPromise::FetchingInvoice { ctx, promise } => { + let result = promise.block_and_take(); + + Some(PromiseResponse::FetchingInvoice { ctx, result }) + } + ZapPromise::SendingNWCInvoice { ctx, promise } => { + let result = promise.block_and_take(); + + Some(PromiseResponse::SendingNWCInvoice { ctx, result }) + } + } +} + +fn is_promise_ready(zap_promise: &ZapPromise) -> bool { + match zap_promise { + ZapPromise::FetchingInvoice { ctx: _, promise } => promise.ready().is_some(), + ZapPromise::SendingNWCInvoice { ctx: _, promise } => promise.ready().is_some(), + } +} + +enum NextState { + Event(EventResponse), + Transition(ZapPromise), + Success { id: ZapId, zap: LocalConfirmedZap }, +} + +#[derive(Debug, Clone)] +struct EventResponse { + id: ZapId, + event: Result<ZapEvent, ZappingError>, +} + +#[allow(dead_code)] +impl Zaps { + fn get_next_id(&mut self) -> ZapId { + let next = self.next_id; + self.next_id += 1; + next + } + + fn send_event(&mut self, id: ZapId, event: ZapEvent) { + self.events.push(EventResponse { + id, + event: Ok(event), + }); + } + + pub fn send_error(&mut self, sender_pubkey: &[u8; 32], target: ZapTarget, error: ZappingError) { + let id = self.get_next_id(); + let key = ZapKey { + sender: sender_pubkey, + target, + }; + + self.insert_new_state(&id, &key, ZapState::Pending(Err(error))); + } + + pub fn send_zap( + &mut self, + sender_pubkey: &[u8; 32], + sender_relays: Vec<String>, + target: ZapTarget, + msats: u64, + ) { + let id = self.get_next_id(); + let key = ZapKey { + sender: sender_pubkey, + target, + }; + let event = ZapEvent::FetchInvoice { + zap_ctx: ZapCtx { + id, + key: (&key).into(), + msats, + }, + sender_relays, + }; + + self.insert_new_state(&id, &key, ZapState::Pending(Ok(event.clone()))); + self.send_event(id, event); + } + + fn insert_new_state(&mut self, id: &ZapId, key: &ZapKey, state: ZapState) { + self.zaps.insert(*id, state); + + let Some(states) = self.zap_keys.get_mut(key) else { + let states: Vec<ZapId> = vec![*id]; + self.zap_keys.insert((key).into(), states); + return; + }; + + states.push(*id); + } + + pub fn process( + &mut self, + accounts: &mut Accounts, + global_wallet: &mut GlobalWallet, + ndb: &Ndb, + ) { + for i in (0..self.in_flight.len()).rev() { + let Some(resp) = try_get_promise_response(&mut self.in_flight, i) else { + continue; + }; + + self.events.push(resp.take_as_event_response()); + } + + while let Some(event_resp) = self.events.pop() { + let event = match event_resp.event { + Ok(ev) => ev, + Err(e) => { + tracing::error!("transitioned to error for id {}: {e}", event_resp.id); + self.zaps.insert(event_resp.id, ZapState::Pending(Err(e))); + continue; + } + }; + + let txn = nostrdb::Transaction::new(ndb).expect("txn"); + match process_event(event_resp.id, event, accounts, global_wallet, ndb, &txn) { + NextState::Event(event_resp) => { + self.zaps + .insert(event_resp.id, ZapState::Pending(event_resp.event)); + } + NextState::Transition(in_flight_promise) => { + self.in_flight.push(in_flight_promise); + } + NextState::Success { id, zap } => { + self.zaps.insert(id, ZapState::LocalConfirm(zap)); + } + } + } + } + + pub fn get_states_for<'a>( + &'a self, + sender: &[u8; 32], + target: ZapTarget<'a>, + ) -> Option<Vec<&'a ZapState>> { + let key = ZapKey { sender, target }; + let ids = self.zap_keys.get(&key)?; + + let mut states = Vec::new(); + for id in ids { + if let Some(state) = self.zaps.get(id) { + states.push(state); + } + } + + if states.is_empty() { + return None; + } + + Some(states) + } + + /// if any of the states are `ZapState::Pending`, all other values will be ignored and `AnyZapState::Pending` will return + /// if there is at least one `ZapState::LocalConfirm`, `AnyZapState::LocalOnly` will return + /// if there are `ZapState::Confirm` and none others, `AnyZapState::Confirmed` will return + /// otherwise `AnyZapState::None` will return + pub fn any_zap_state_for<'a>( + &'a self, + sender: &[u8; 32], + target: ZapTarget<'a>, + ) -> AnyZapState { + let key = ZapKey { sender, target }; + let Some(ids) = self.zap_keys.get(&key) else { + return AnyZapState::None; + }; + + let mut has_confirmed = false; + let mut has_local_confirmed = false; + + for id in ids { + let Some(state) = self.zaps.get(id) else { + continue; + }; + + match state { + ZapState::Confirm(_) => { + has_confirmed = true; + } + ZapState::LocalConfirm(_) => { + has_local_confirmed = true; + } + ZapState::Pending(p) => { + if let Err(e) = p { + return AnyZapState::Error(e.to_owned()); + } + return AnyZapState::Pending; + } + } + } + + if has_local_confirmed { + return AnyZapState::LocalOnly; + } + + if has_confirmed { + AnyZapState::Confirmed + } else { + AnyZapState::None + } + } + + pub fn clear_error_for(&mut self, sender: &[u8; 32], target: ZapTarget<'_>) { + let key = ZapKey { sender, target }; + let Some(ids) = self.zap_keys.get_mut(&key) else { + return; + }; + + ids.retain(|id| { + let should_keep = !matches!(self.zaps.get(id), Some(ZapState::Pending(Err(_)))); + if !should_keep { + self.zaps.remove(id); + } + should_keep + }); + } +} + +pub enum AnyZapState { + None, + Pending, + #[allow(dead_code)] + Error(ZappingError), + LocalOnly, + Confirmed, +} + +#[allow(dead_code)] +#[derive(Debug)] +pub enum ZapState { + Confirm(Zap), + LocalConfirm(LocalConfirmedZap), + Pending(Result<ZapEvent, ZappingError>), +} + +#[allow(dead_code)] +#[derive(Debug)] +pub struct LocalConfirmedZap { + request_noteid: NoteId, + sender: Pubkey, + target: ZapTargetOwned, + msats: u64, +} + +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +struct ZapKeyOwned { + sender: Pubkey, + target: ZapTargetOwned, +} + +#[derive(Debug, Hash)] +struct ZapKey<'a> { + sender: &'a [u8; 32], + target: ZapTarget<'a>, +} + +struct SendingNWCInvoiceContext { + request_noteid: NoteId, + zap_ctx: ZapCtx, +} + +#[derive(Clone, Debug)] +pub struct ZapCtx { + id: ZapId, + key: ZapKeyOwned, + msats: u64, +} + +#[allow(dead_code)] +#[derive(Clone, Debug)] +pub enum ZapEvent { + FetchInvoice { + zap_ctx: ZapCtx, + sender_relays: Vec<String>, + }, + SendNWC { + zap_ctx: ZapCtx, + req_noteid: NoteId, + invoice: String, + }, + EndpointConfirmed { + zap_ctx: ZapCtx, + req_noteid: NoteId, + }, +} + +#[allow(dead_code)] +#[derive(Clone, Debug)] +pub enum ZappingError { + InvoiceFetchFailed(ZapError), + InvalidAccount, + UnsupportedOperation, // TODO(kernelkind): support profile zaps + InvalidZapAddress, + SenderNoWallet, + InvalidNWCResponse(String), + FutureError(String), +} + +impl std::fmt::Display for ZappingError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + ZappingError::InvoiceFetchFailed(err) => write!(f, "Failed to fetch invoice: {}", err), + ZappingError::InvalidAccount => write!(f, "Invalid account"), + ZappingError::UnsupportedOperation => { + write!(f, "Unsupported operation (e.g. profile zaps)") + } + ZappingError::InvalidZapAddress => write!(f, "Invalid zap address"), + ZappingError::SenderNoWallet => write!(f, "Sender has no wallet"), + ZappingError::InvalidNWCResponse(msg) => write!(f, "Invalid NWC response: {}", msg), + ZappingError::FutureError(msg) => write!(f, "Future error: {}", msg), + } + } +} + +enum ZapPromise { + FetchingInvoice { + ctx: ZapCtx, + promise: Promise<Result<Result<FetchedInvoice, ZapError>, JoinError>>, + }, + SendingNWCInvoice { + ctx: SendingNWCInvoiceContext, + promise: Promise<Result<PayInvoiceResponse, nwc::Error>>, + }, +} + +enum PromiseResponse { + FetchingInvoice { + ctx: ZapCtx, + result: Result<Result<FetchedInvoice, ZapError>, JoinError>, + }, + SendingNWCInvoice { + ctx: SendingNWCInvoiceContext, + result: Result<PayInvoiceResponse, nwc::Error>, + }, +} + +impl PromiseResponse { + pub fn take_as_event_response(self) -> EventResponse { + match self { + PromiseResponse::FetchingInvoice { ctx, result } => { + let id = ctx.id; + let event = match result { + Ok(r) => match r { + Ok(invoice) => Ok(ZapEvent::SendNWC { + zap_ctx: ctx, + req_noteid: invoice.request_noteid, + invoice: invoice.invoice, + }), + Err(e) => { + tracing::error!("NWC error: {e}"); + Err(ZappingError::InvoiceFetchFailed(e)) + } + }, + Err(e) => Err(ZappingError::FutureError(e.to_string())), + }; + + EventResponse { id, event } + } + PromiseResponse::SendingNWCInvoice { ctx, result } => { + let id = ctx.zap_ctx.id; + let event = match result { + Ok(_) => Ok(ZapEvent::EndpointConfirmed { + zap_ctx: ctx.zap_ctx, + req_noteid: ctx.request_noteid, + }), + Err(e) => Err(ZappingError::InvalidNWCResponse(e.to_string())), + }; + + EventResponse { id, event } + } + } + } +} + +#[allow(dead_code)] +#[derive(Debug, PartialEq, Eq, Clone, Hash)] +enum ZapTargetOwned { + Profile(Pubkey), + Note(NoteZapTargetOwned), +} + +#[allow(dead_code)] +#[derive(Debug, Hash)] +pub enum ZapTarget<'a> { + Profile(&'a [u8; 32]), + Note(NoteZapTarget<'a>), +} + +#[allow(dead_code)] +impl ZapTargetOwned { + pub fn pubkey(&self) -> &Pubkey { + match &self { + ZapTargetOwned::Profile(pubkey) => pubkey, + ZapTargetOwned::Note(note_zap_target) => &note_zap_target.zap_recipient, + } + } +} + +#[allow(dead_code)] +#[derive(Debug, PartialEq, Eq, Clone, Hash)] +pub struct NoteZapTargetOwned { + pub note_id: NoteId, + pub zap_recipient: Pubkey, +} + +#[derive(Debug, Hash)] +pub struct NoteZapTarget<'a> { + pub note_id: &'a [u8; 32], + pub zap_recipient: &'a [u8; 32], +} + +impl From<&NoteZapTarget<'_>> for NoteZapTargetOwned { + fn from(value: &NoteZapTarget) -> Self { + Self { + note_id: NoteId::new(*value.note_id), + zap_recipient: Pubkey::new(*value.zap_recipient), + } + } +} + +impl<'a> From<&'a NoteZapTargetOwned> for NoteZapTarget<'a> { + fn from(value: &'a NoteZapTargetOwned) -> Self { + Self { + note_id: value.note_id.bytes(), + zap_recipient: value.zap_recipient.bytes(), + } + } +} + +impl From<&ZapTarget<'_>> for ZapTargetOwned { + fn from(value: &ZapTarget) -> Self { + match value { + ZapTarget::Profile(pubkey) => ZapTargetOwned::Profile(Pubkey::new(**pubkey)), + ZapTarget::Note(note_zap_target) => ZapTargetOwned::Note(note_zap_target.into()), + } + } +} + +impl From<&ZapKey<'_>> for ZapKeyOwned { + fn from(value: &ZapKey) -> Self { + Self { + sender: Pubkey::new(*value.sender), + target: (&value.target).into(), + } + } +} + +impl hashbrown::Equivalent<ZapKeyOwned> for ZapKey<'_> { + fn equivalent(&self, key: &ZapKeyOwned) -> bool { + if key.sender.bytes() != self.sender { + return false; + } + + match (&self.target, &key.target) { + (ZapTarget::Profile(a), ZapTargetOwned::Profile(b)) => *a == b.bytes(), + (ZapTarget::Note(a), ZapTargetOwned::Note(b)) => { + a.note_id == b.note_id.bytes() && a.zap_recipient == b.zap_recipient.bytes() + } + _ => false, + } + } +} diff --git a/crates/notedeck/src/zaps/mod.rs b/crates/notedeck/src/zaps/mod.rs @@ -1,2 +1,3 @@ +mod cache; mod networking; mod zap; \ No newline at end of file