From 53aa54a034fcc811d21e3d6ae839efc578c39f22 Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Fri, 6 Nov 2026 18:04:31 +0700 Subject: [PATCH] add nip60 wallet ui and nutzap sending --- Cargo.lock | 17 + crates/chat_ui/Cargo.toml | 3 + crates/chat_ui/src/lib.rs | 47 +++ crates/nip60/Cargo.toml | 1 + crates/nip60/src/keys.rs | 77 ++++ crates/nip60/src/lib.rs | 114 +++++- crates/nip60/src/ops.rs | 177 ++++++++- crates/nip60_ui/Cargo.toml | 15 + crates/nip60_ui/src/lib.rs | 79 ++++ crates/nip60_ui/src/wallet.rs | 422 +++++++++++++++++++++ crates/workspace/Cargo.toml | 8 +- crates/workspace/src/dialogs/join_group.rs | 6 +- crates/workspace/src/lib.rs | 31 ++ crates/workspace/src/sidebar/list.rs | 14 + docs/nip60.md | 38 +- 15 files changed, 1033 insertions(+), 16 deletions(-) create mode 100644 crates/nip60_ui/Cargo.toml create mode 100644 crates/nip60_ui/src/lib.rs create mode 100644 crates/nip60_ui/src/wallet.rs diff --git a/Cargo.lock b/Cargo.lock index 0b5a8d27..af7a0b33 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1462,6 +1462,7 @@ dependencies = [ "gpui-pre", "instant", "itertools 0.13.0", + "nip60_ui", "nostr-sdk", "person", "regex", @@ -6123,6 +6124,7 @@ dependencies = [ "cdk-redb", "common", "flume 0.11.1", + "futures", "gpui-pre", "gpui_tokio", "hkdf", @@ -6136,6 +6138,19 @@ dependencies = [ "tokio", ] +[[package]] +name = "nip60_ui" +version = "2.0.0-alpha3" +dependencies = [ + "anyhow", + "common", + "gpui-kit", + "gpui-pre", + "nip60", + "nostr-sdk", + "smallvec", +] + [[package]] name = "no_std_io2" version = "0.9.4" @@ -11944,6 +11959,8 @@ dependencies = [ "nip29", "nip29_ui", "nip4e", + "nip60", + "nip60_ui", "nostr-connect", "nostr-sdk", "person", diff --git a/crates/chat_ui/Cargo.toml b/crates/chat_ui/Cargo.toml index d14400cf..d41e9f78 100644 --- a/crates/chat_ui/Cargo.toml +++ b/crates/chat_ui/Cargo.toml @@ -23,3 +23,6 @@ smallvec.workspace = true serde.workspace = true regex = "1" + +[target.'cfg(not(target_arch = "wasm32"))'.dependencies] +nip60_ui = { path = "../nip60_ui" } diff --git a/crates/chat_ui/src/lib.rs b/crates/chat_ui/src/lib.rs index e9719a86..03386f79 100644 --- a/crates/chat_ui/src/lib.rs +++ b/crates/chat_ui/src/lib.rs @@ -681,6 +681,41 @@ impl ChatPanel { }); } + /// Open the nutzap dialog for the room's peer; direct chats only + fn open_nutzap(&mut self, window: &mut Window, cx: &mut Context) { + #[cfg(target_arch = "wasm32")] + let _ = (window, cx); + + #[cfg(not(target_arch = "wasm32"))] + { + let Some(room) = self.room.upgrade() else { + return; + }; + + let nostr = NostrRegistry::global(cx); + let Some(me) = nostr.read(cx).current_user() else { + return; + }; + + let peers: Vec = room + .read(cx) + .members() + .iter() + .copied() + .filter(|key| *key != me) + .collect(); + + if let [recipient] = peers[..] { + nip60_ui::open_nutzap(window, cx, recipient, None); + } else { + window.push_notification( + Notification::warning("Nutzaps can only be sent in direct chats"), + cx, + ); + } + } + } + fn upload(&mut self, window: &mut Window, cx: &mut Context) { // Get the user's configured blossom server let server = AppSettings::get_file_server(cx); @@ -2012,6 +2047,18 @@ impl Render for ChatPanel { this.upload(window, cx); })), ) + .when(cfg!(not(target_arch = "wasm32")), |this| { + this.child( + Button::new("nutzap") + .icon(gpui_kit::assets::IconName::Coins) + .tooltip("Send eCash") + .disabled(dictating) + .ghost() + .on_click(cx.listener(move |this, _ev, window, cx| { + this.open_nutzap(window, cx); + })), + ) + }) .when(dictating, |this| { this.child( h_flex() diff --git a/crates/nip60/Cargo.toml b/crates/nip60/Cargo.toml index fe2ed07c..f67f1109 100644 --- a/crates/nip60/Cargo.toml +++ b/crates/nip60/Cargo.toml @@ -17,6 +17,7 @@ smallvec.workspace = true log.workspace = true flume.workspace = true serde_json.workspace = true +futures.workspace = true hkdf.workspace = true sha2.workspace = true tokio = { version = "1", default-features = false, features = [ "rt" ] } diff --git a/crates/nip60/src/keys.rs b/crates/nip60/src/keys.rs index 27adf39d..c0af301d 100644 --- a/crates/nip60/src/keys.rs +++ b/crates/nip60/src/keys.rs @@ -159,6 +159,83 @@ where .await?) } +/// Decrypted 7376 payload, enough for the wallet panel's history list. +pub(crate) struct HistoryPayload { + pub(crate) direction: TransactionDirection, + pub(crate) amount: u64, +} + +pub(crate) async fn decrypt_history_event( + signer: &S, + me: &PublicKey, + event: &Event, +) -> Result +where + S: AsyncNip44 + ?Sized, +{ + if event.pubkey != *me { + return Err(anyhow!("history event was not published by this account")); + } + + let content = signer.nip44_decrypt_async(me, &event.content).await?; + let rows: Vec> = serde_json::from_str(&content) + .map_err(|error| anyhow!("invalid history event: {error}"))?; + + let mut direction = None; + let mut amount = None; + for row in rows { + let (Some(key), Some(value)) = (row.first(), row.get(1)) else { + continue; + }; + match key.as_str() { + "direction" if direction.is_none() => direction = value.parse().ok(), + "amount" if amount.is_none() => amount = value.parse().ok(), + _ => {} + } + } + + let Some(direction) = direction else { + return Err(anyhow!("history event is missing the direction")); + }; + let Some(amount) = amount else { + return Err(anyhow!("history event is missing the amount")); + }; + + Ok(HistoryPayload { direction, amount }) +} + +/// Build the public kind 9321 nutzap event carrying P2PK-locked proofs. +pub(crate) async fn build_nutzap_event( + signer: &S, + recipient: PublicKey, + mint: &str, + proofs: &[String], + target: Option, + comment: &str, +) -> Result +where + S: AsyncGetPublicKey + AsyncSignEvent + ?Sized, +{ + let mut tags: Vec = vec![ + Tag::custom("p", [recipient.to_hex()]), + Tag::custom("u", [mint.to_string()]), + Tag::custom("unit", ["sat".to_string()]), + ]; + + for proof in proofs { + tags.push(Tag::custom("proof", [proof.clone()])); + } + + if let Some(target) = target { + tags.push(Tag::custom("e", [target.to_hex()])); + } + + Ok(EventBuilder::new(Kind::Custom(9321), comment) + .tags(tags) + .finalize_async(signer) + .await?) +} + /// Encrypted 7376 history payload for one state change. pub(crate) struct History { pub(crate) direction: TransactionDirection, diff --git a/crates/nip60/src/lib.rs b/crates/nip60/src/lib.rs index bc306152..4e2473ce 100644 --- a/crates/nip60/src/lib.rs +++ b/crates/nip60/src/lib.rs @@ -46,12 +46,23 @@ pub struct MintQuoteEntry { pub mint: MintUrl, } +/// One spending history entry, newest first in `WalletRegistry::history`. +#[derive(Debug, Clone)] +pub struct HistoryEntry { + pub id: EventId, + pub direction: TransactionDirection, + pub amount: u64, + pub created_at: Timestamp, +} + pub struct WalletRegistry { wallet: Option, tokens: HashMap, /// Token events killed by a kind 5 delete or superseded by a `del` chain stale: HashSet, quotes: Vec, + /// Spending history entries, newest first + history: Vec, /// NIP-61 nutzap events already claimed, recorded by our 7376 events redeemed: HashSet, /// Nutzaps seen but not yet redeemed (wallet was not ready) @@ -73,6 +84,7 @@ struct LoadedState { wallet: Option, tokens: HashMap, stale: HashSet, + history: Vec, redeemed: HashSet, nutzaps: Vec, } @@ -103,6 +115,7 @@ impl WalletRegistry { tokens: HashMap::new(), stale: HashSet::new(), quotes: Vec::new(), + history: Vec::new(), redeemed: HashSet::new(), pending_nutzaps: Vec::new(), redeeming: HashSet::new(), @@ -135,6 +148,11 @@ impl WalletRegistry { &self.quotes } + /// Spending history, newest first. + pub fn history(&self) -> &[HistoryEntry] { + &self.history + } + /// Create a lightning invoice that funds the wallet once paid. pub fn create_mint_quote(&mut self, amount: u64, cx: &mut Context) { let Some(mint_url) = self.primary_mint(cx) else { @@ -196,6 +214,31 @@ impl WalletRegistry { ); } + /// Send a NIP-61 nutzap to a peer, optionally nutzapping one of their events. + pub fn send_nutzap( + &mut self, + recipient: PublicKey, + amount: u64, + target: Option, + comment: Option, + cx: &mut Context, + ) { + if self.wallet.is_none() { + self.emit_error("No wallet is set up".to_string(), cx); + return; + } + + self.schedule_job( + ops::Job::SendNutzap { + recipient, + amount, + target, + comment, + }, + cx, + ); + } + /// The first wallet mint; the UI is effectively single-mint in v1. fn primary_mint(&self, cx: &mut Context) -> Option { let Some(mint) = self.wallet.as_ref().and_then(|wallet| wallet.mints.first()) else { @@ -331,6 +374,7 @@ impl WalletRegistry { self.tokens.clear(); self.stale.clear(); self.quotes.clear(); + self.history.clear(); self.redeemed.clear(); self.pending_nutzaps.clear(); self.redeeming.clear(); @@ -412,7 +456,7 @@ impl WalletRegistry { match event.kind { Kind::CashuWallet => self.absorb_wallet(event, cx), Kind::CashuWalletUnspentProof => self.absorb_token(event, cx), - Kind::CashuWalletSpendingHistory => {} + Kind::CashuWalletSpendingHistory => self.absorb_history(event, cx), Kind::EventDeletion => self.absorb_deletion(event, cx), Kind::Custom(9321) => self.absorb_nutzap(event, cx), _ => {} @@ -527,6 +571,46 @@ impl WalletRegistry { } } + fn absorb_history(&mut self, event: Event, cx: &mut Context) { + let event_id = event.id; + let created_at = event.created_at; + + if self.history.iter().any(|entry| entry.id == event_id) { + return; + } + + let nostr = NostrRegistry::global(cx); + let signer = nostr.read(cx).signer().clone(); + + let decrypted = cx.background_spawn(async move { + let me = signer.get_public_key_async().await?; + keys::decrypt_history_event(&signer, &me, &event).await + }); + + self.tasks.push(cx.spawn(async move |this, cx| { + let payload = match decrypted.await { + Ok(payload) => payload, + Err(error) => { + log::warn!("skipping unreadable history event {event_id}: {error:#}"); + return; + } + }; + + this.update(cx, |this, cx| { + this.history.push(HistoryEntry { + id: event_id, + direction: payload.direction, + amount: payload.amount, + created_at, + }); + this.history + .sort_by_key(|entry| std::cmp::Reverse(entry.created_at)); + this.emit_updated(cx); + }) + .ok(); + })); + } + /// Claim a NIP-61 nutzap. fn absorb_nutzap(&mut self, event: Event, cx: &mut Context) { if self.redeemed.contains(&event.id) || self.redeeming.contains(&event.id) { @@ -805,6 +889,11 @@ impl WalletRegistry { cx.emit(WalletEvent::TokenCreated(token)); self.emit_updated(cx); } + ops::Outcome::NutzapSent { update, amount } => { + self.apply_token_update(update); + self.emit_updated(cx); + log::info!("sent {amount} sats as a nutzap"); + } ops::Outcome::Received { update, amount } => { self.apply_token_update(update); self.emit_updated(cx); @@ -836,6 +925,7 @@ impl WalletRegistry { self.wallet = state.wallet; self.tokens = state.tokens; self.stale = state.stale; + self.history = state.history; self.redeemed = state.redeemed; cx.notify(); self.ensure_wallets(cx); @@ -996,12 +1086,31 @@ impl LoadedState { // Nutzap events tagged redeemed in our history events are already claimed let mut redeemed = HashSet::new(); - for event in history_events { + for event in &history_events { for id in redeemed_ids(event) { redeemed.insert(id); } } + let mut history = Vec::new(); + for event in &history_events { + let payload = match keys::decrypt_history_event(signer, &me, event).await { + Ok(payload) => payload, + Err(error) => { + log::warn!("skipping unreadable history event {}: {error:#}", event.id); + continue; + } + }; + + history.push(HistoryEntry { + id: event.id, + direction: payload.direction, + amount: payload.amount, + created_at: event.created_at, + }); + } + history.sort_by_key(|entry| std::cmp::Reverse(entry.created_at)); + let nutzaps = nutzap_events .into_iter() .filter(|event| !redeemed.contains(&event.id)) @@ -1063,6 +1172,7 @@ impl LoadedState { wallet, tokens, stale, + history, redeemed, nutzaps, }) diff --git a/crates/nip60/src/ops.rs b/crates/nip60/src/ops.rs index 5e2e020e..722c37e4 100644 --- a/crates/nip60/src/ops.rs +++ b/crates/nip60/src/ops.rs @@ -1,5 +1,6 @@ use std::collections::{HashMap, HashSet}; use std::str::FromStr; +use std::time::Duration; use anyhow::{Error, anyhow}; use cdk::Amount; @@ -7,17 +8,21 @@ use cdk::amount::SplitTarget; use cdk::mint_url::MintUrl; use cdk::nuts::nut00::KnownMethod; use cdk::nuts::{ - CurrencyUnit, Id, MeltQuoteState, MintQuoteState, PaymentMethod, Proof, Proofs, SecretKey, - State, + CurrencyUnit, Id, KeySetInfo, MeltQuoteState, MintQuoteState, PaymentMethod, Proof, Proofs, + SecretKey, SpendingConditions, State, }; use cdk::secret::Secret; use cdk::wallet::types::ProofInfo; use cdk::wallet::{ReceiveOptions, SendOptions, Wallet}; +use futures::StreamExt; use nostr::nips::nip60::{CashuProof, TokenEvent, TransactionDirection}; use nostr_sdk::prelude::*; use crate::keys; +/// How long to wait for relays when fetching a recipient's wallet info +const FETCH_TIMEOUT: Duration = Duration::from_secs(5); + /// A job mutating the wallet store or reconciling it with relays. #[derive(Debug, Clone)] pub(crate) enum Job { @@ -38,6 +43,12 @@ pub(crate) enum Job { mint: MintUrl, amount: u64, }, + SendNutzap { + recipient: PublicKey, + amount: u64, + target: Option, + comment: Option, + }, Receive { mint: MintUrl, token: String, @@ -167,6 +178,74 @@ impl Job { Ok(Outcome::Sent { update, token }) } + Job::SendNutzap { + recipient, + amount, + target, + comment, + } => { + let (mint_url, mint_tag, p2pk) = recipient_wallet_info(&ctx, recipient).await?; + let wallet = ctx.wallet(&mint_url)?; + + let keysets = wallet + .keysets(cdk::wallet::types::KeysetLoadPolicy::CacheOnly) + .await?; + + let infos: Vec = keysets + .iter() + .map(|keyset| KeySetInfo { + id: keyset.id, + unit: keyset.unit.clone(), + active: keyset.active.unwrap_or(true), + input_fee_ppk: keyset.input_fee_ppk, + final_expiry: keyset.final_expiry, + }) + .collect(); + + let options = SendOptions { + conditions: Some(SpendingConditions::new_p2pk(p2pk, None)), + ..Default::default() + }; + + let token = wallet + .prepare_send(Amount::from(amount), options) + .await? + .confirm(None) + .await?; + let proofs = token.proofs(&infos)?; + + let mut tags = Vec::new(); + for proof in &proofs { + tags.push(serde_json::to_string(proof)?); + } + + let event = keys::build_nutzap_event( + &ctx.signer, + recipient, + &mint_tag, + &tags, + target, + comment.as_deref().unwrap_or(""), + ) + .await?; + ctx.client.send_event(&event).await?; + + let update = ctx + .sync_token_events( + wallet, + nostr_mint(&mint_url)?, + &ctx.previous_ids(&mint_url), + Some(keys::History { + direction: TransactionDirection::Out, + amount, + redeemed: Vec::new(), + sender: None, + }), + ) + .await?; + + Ok(Outcome::NutzapSent { update, amount }) + } Job::Receive { mint, token } => { let wallet = ctx.wallet(&mint)?; let received = wallet.receive(&token, ReceiveOptions::default()).await?; @@ -259,6 +338,10 @@ pub(crate) enum Outcome { update: TokenUpdate, token: String, }, + NutzapSent { + update: TokenUpdate, + amount: u64, + }, Received { update: TokenUpdate, amount: u64, @@ -339,7 +422,9 @@ where let mut updates = Vec::new(); for (mint_url, wallet) in &self.wallets { - wallet.recover_incomplete_sagas().await?; + if let Err(error) = wallet.recover_incomplete_sagas().await { + log::warn!("saga recovery failed for {mint_url}: {error:#}"); + } let minted = wallet.mint_unissued_quotes().await?; if minted == Amount::ZERO { @@ -414,6 +499,92 @@ fn previous_ids(tokens: &HashMap, mint: &MintUrl) -> Vec( + ctx: &JobCtx, + recipient: PublicKey, +) -> Result<(MintUrl, String, cdk::nuts::PublicKey), Error> +where + S: AsyncGetPublicKey + AsyncSignEvent + AsyncNip44 + Sync, +{ + let filter = Filter::new() + .author(recipient) + .kind(Kind::Custom(10019)) + .limit(1); + + let info = match ctx + .client + .database() + .query(filter.clone()) + .await? + .into_iter() + .next() + { + Some(event) => event, + None => { + let mut stream = ctx + .client + .stream_events(filter) + .timeout(FETCH_TIMEOUT) + .await?; + let mut found: Option = None; + + while let Some((_url, result)) = stream.next().await { + if let Ok(event) = result { + let newer = found + .as_ref() + .is_none_or(|current| event.created_at > current.created_at); + + if newer { + found = Some(event); + } + } + } + + found.ok_or_else(|| anyhow!("recipient has no wallet info"))? + } + }; + + let (p2pk, mints) = parse_nutzap_info(&info)?; + + for mint in mints { + let Ok(mint_url) = MintUrl::from_str(&mint) else { + continue; + }; + + if ctx.wallets.contains_key(&mint_url) { + return Ok((mint_url, mint, p2pk)); + } + } + + Err(anyhow!("no shared mint with the recipient")) +} + +fn parse_nutzap_info(event: &Event) -> Result<(cdk::nuts::PublicKey, Vec), Error> { + let mut p2pk = None; + let mut mints = Vec::new(); + + for tag in event.tags.iter() { + let Some(value) = tag.content() else { + continue; + }; + + match tag.kind() { + "pubkey" if p2pk.is_none() => { + p2pk = Some(cdk::nuts::PublicKey::from_str(value)?); + } + "mint" => mints.push(value.to_string()), + _ => {} + } + } + + let Some(p2pk) = p2pk else { + return Err(anyhow!("recipient has no wallet pubkey")); + }; + + Ok((p2pk, mints)) +} + fn cashu_proof(proof: &Proof) -> Result { Ok(CashuProof { id: proof.keyset_id.to_string(), diff --git a/crates/nip60_ui/Cargo.toml b/crates/nip60_ui/Cargo.toml new file mode 100644 index 00000000..b049fcda --- /dev/null +++ b/crates/nip60_ui/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "nip60_ui" +version.workspace = true +edition.workspace = true +publish.workspace = true + +[dependencies] +common = { path = "../common" } +nip60 = { path = "../nip60" } + +gpui.workspace = true +gpui-kit.workspace = true +nostr-sdk.workspace = true +anyhow.workspace = true +smallvec.workspace = true diff --git a/crates/nip60_ui/src/lib.rs b/crates/nip60_ui/src/lib.rs new file mode 100644 index 00000000..6f489605 --- /dev/null +++ b/crates/nip60_ui/src/lib.rs @@ -0,0 +1,79 @@ +#![cfg(not(target_arch = "wasm32"))] + +use std::rc::Rc; + +use gpui::{App, AppContext, ClickEvent, ParentElement, Styled, Window, px}; +use gpui_kit::component::button::{Button, ButtonVariants}; +use gpui_kit::component::input::{Input, InputState}; +use gpui_kit::component::notification::Notification; +use gpui_kit::component::{WindowExt, h_flex, v_flex}; +use nip60::WalletRegistry; +use nostr_sdk::prelude::{EventId, PublicKey}; + +pub mod wallet; + +type SubmitFn = Rc bool>; + +/// Ask for an amount and send a nutzap to `recipient`, optionally +/// nutzapping the event with `target`. +pub fn open_nutzap( + window: &mut Window, + cx: &mut App, + recipient: PublicKey, + target: Option, +) { + let input = cx.new(|cx| InputState::new(window, cx).placeholder("Amount (sats)")); + + let send: SubmitFn = { + let input = input.clone(); + Rc::new(move |_, window, cx| { + let amount = match input.read(cx).value().trim().parse::() { + Ok(amount) if amount > 0 => amount, + _ => { + window.push_notification(Notification::error("Enter an amount in sats"), cx); + return false; + } + }; + + let wallet = WalletRegistry::global(cx); + wallet.update(cx, |registry, cx| { + registry.send_nutzap(recipient, amount, target, None, cx); + }); + + true + }) + }; + + window.open_dialog(cx, move |this, _window, _cx| { + this.width(px(380.)) + .title("Send Nutzap") + .on_ok({ + let send = send.clone(); + move |event, window, cx| send(event, window, cx) + }) + .child( + v_flex() + .gap_3() + .child(Input::new(&input).cleanable(true)) + .child( + h_flex() + .gap_1() + .justify_end() + .child( + Button::new("cancel") + .label("Cancel") + .ghost() + .on_click(|_, window, cx| window.close_dialog(cx)), + ) + .child(Button::new("send").label("Send").primary().on_click({ + let send = send.clone(); + move |event, window, cx| { + if send(event, window, cx) { + window.close_dialog(cx); + } + } + })), + ), + ) + }); +} diff --git a/crates/nip60_ui/src/wallet.rs b/crates/nip60_ui/src/wallet.rs new file mode 100644 index 00000000..7026f4cd --- /dev/null +++ b/crates/nip60_ui/src/wallet.rs @@ -0,0 +1,422 @@ +use ::nip60::{HistoryEntry, MintQuoteEntry, WalletEvent, WalletRegistry}; +use common::TimestampExt; +use gpui::prelude::FluentBuilder; +use gpui::{ + App, AppContext, Context, Entity, EventEmitter, FocusHandle, Focusable, IntoElement, + ParentElement, Render, SharedString, Styled, Subscription, Window, div, +}; +use gpui_kit::assets::IconName; +use gpui_kit::base::StyledExt as _; +use gpui_kit::component::button::{Button, ButtonVariants as _}; +use gpui_kit::component::clipboard::Clipboard; +use gpui_kit::component::dock::{Panel, PanelEvent}; +use gpui_kit::component::input::{Input, InputState}; +use gpui_kit::component::notification::Notification; +use gpui_kit::component::scroll::ScrollableElement as _; +use gpui_kit::component::separator::Separator; +use gpui_kit::component::{ActiveTheme, Icon, Sizable, WindowExt, h_flex, v_flex}; +use nostr_sdk::prelude::*; +use smallvec::SmallVec; +pub fn init(window: &mut Window, cx: &mut App) -> Entity { + cx.new(|cx| WalletPanel::new(window, cx)) +} + +pub struct WalletPanel { + name: SharedString, + focus_handle: FocusHandle, + + /// Mint URL input for the setup flow + mint_input: Entity, + /// Amount input for sending a cashu token + send_input: Entity, + /// Amount input for the funding invoice + invoice_input: Entity, + /// Pasted token input for receiving ecash + token_input: Entity, + /// The token produced by the last send, shown for copying + sent_token: Option, + + _subscriptions: SmallVec<[Subscription; 1]>, +} + +impl WalletPanel { + fn new(window: &mut Window, cx: &mut Context) -> Self { + let mint_input = cx.new(|cx| InputState::new(window, cx).placeholder("https://mint…")); + let send_input = cx.new(|cx| InputState::new(window, cx).placeholder("Amount (sats)")); + let invoice_input = cx.new(|cx| InputState::new(window, cx).placeholder("Amount (sats)")); + let token_input = cx.new(|cx| InputState::new(window, cx).placeholder("cashu…")); + + let wallet = WalletRegistry::global(cx); + let mut subscriptions = SmallVec::new(); + subscriptions.push( + cx.subscribe_in( + &wallet, + window, + |this, _wallet, event, window, cx| match event { + WalletEvent::Updated => cx.notify(), + WalletEvent::Error(error) => { + window.push_notification(Notification::error(error.clone()), cx); + } + WalletEvent::TokenCreated(token) => { + this.sent_token = Some(token.clone().into()); + cx.notify(); + } + }, + ), + ); + + Self { + name: "Wallet".into(), + focus_handle: cx.focus_handle(), + mint_input, + send_input, + invoice_input, + token_input, + sent_token: None, + _subscriptions: subscriptions, + } + } + + fn parse_amount(&self, input: &Entity, cx: &Context) -> Option { + match input.read(cx).value().trim().parse::() { + Ok(amount) if amount > 0 => Some(amount), + _ => None, + } + } + + fn setup(&mut self, window: &mut Window, cx: &mut Context) { + let mint = self.mint_input.read(cx).value().trim().to_string(); + if mint.is_empty() { + window.push_notification(Notification::error("Enter a mint URL"), cx); + return; + } + + WalletRegistry::global(cx).update(cx, |registry, cx| { + registry.setup(mint, cx); + }); + } + + fn send_token(&mut self, window: &mut Window, cx: &mut Context) { + let Some(amount) = self.parse_amount(&self.send_input, cx) else { + window.push_notification(Notification::error("Enter an amount in sats"), cx); + return; + }; + + WalletRegistry::global(cx).update(cx, |registry, cx| { + registry.send_token(amount, cx); + }); + } + + fn create_invoice(&mut self, window: &mut Window, cx: &mut Context) { + let Some(amount) = self.parse_amount(&self.invoice_input, cx) else { + window.push_notification(Notification::error("Enter an amount in sats"), cx); + return; + }; + + WalletRegistry::global(cx).update(cx, |registry, cx| { + registry.create_mint_quote(amount, cx); + }); + } + + fn claim_quote(&mut self, quote_id: String, cx: &mut Context) { + WalletRegistry::global(cx).update(cx, |registry, cx| { + registry.claim_mint_quote(quote_id, cx); + }); + } + + fn receive_token(&mut self, window: &mut Window, cx: &mut Context) { + let token = self.token_input.read(cx).value().trim().to_string(); + if token.is_empty() { + window.push_notification(Notification::error("Paste a cashu token"), cx); + return; + } + + WalletRegistry::global(cx).update(cx, |registry, cx| { + registry.receive_token(token, cx); + }); + } + + fn render_balance(&self, cx: &Context) -> impl IntoElement { + let balance = WalletRegistry::global(cx).read(cx).balance(); + + h_flex() + .gap_2() + .items_end() + .child( + div() + .text_2xl() + .child(SharedString::from(balance.to_string())), + ) + .child( + div() + .text_xs() + .text_color(cx.theme().muted_foreground) + .child("sats"), + ) + } + + fn render_setup(&self, cx: &mut Context) -> impl IntoElement { + let has_wallet = WalletRegistry::global(cx).read(cx).has_wallet(); + + v_flex() + .gap_1p5() + .w_full() + .child( + div() + .text_xs() + .font_semibold() + .text_color(cx.theme().muted_foreground) + .child(SharedString::from(if has_wallet { + "Add a mint:" + } else { + "Set up your wallet with a mint:" + })), + ) + .child( + h_flex() + .gap_1() + .w_full() + .child(Input::new(&self.mint_input).cleanable(true)) + .child( + Button::new("setup") + .label(if has_wallet { "Add" } else { "Set up" }) + .primary() + .small() + .on_click(cx.listener(move |this, _ev, window, cx| { + this.setup(window, cx); + })), + ), + ) + } + + fn render_send(&self, cx: &Context) -> impl IntoElement { + v_flex() + .gap_1p5() + .w_full() + .child( + div() + .text_xs() + .font_semibold() + .text_color(cx.theme().muted_foreground) + .child("Send ecash:"), + ) + .child( + h_flex() + .gap_1() + .w_full() + .child(Input::new(&self.send_input).cleanable(true)) + .child( + Button::new("send") + .label("Send") + .small() + .on_click(cx.listener(move |this, _ev, window, cx| { + this.send_token(window, cx); + })), + ), + ) + .when_some(self.sent_token.as_ref(), |this, token| { + this.child( + h_flex() + .gap_1() + .w_full() + .child( + div() + .flex_1() + .min_w_0() + .truncate() + .text_color(cx.theme().muted_foreground) + .child(token.clone()), + ) + .child( + Clipboard::new("copy-token") + .accessibility_label("Copy cashu token") + .value_fn({ + let token = token.clone(); + move |_, _| SharedString::from(token.to_string()) + }), + ), + ) + }) + } + + fn render_receive(&self, cx: &Context) -> impl IntoElement { + let quotes = WalletRegistry::global(cx).read(cx).mint_quotes().to_vec(); + + let mut section = + v_flex() + .gap_1p5() + .w_full() + .child( + div() + .text_xs() + .font_semibold() + .text_color(cx.theme().muted_foreground) + .child("Receive:"), + ) + .child( + h_flex() + .gap_1() + .w_full() + .child(Input::new(&self.invoice_input).cleanable(true)) + .child(Button::new("invoice").label("Invoice").small().on_click( + cx.listener(move |this, _ev, window, cx| { + this.create_invoice(window, cx); + }), + )), + ); + + for quote in quotes { + section = section.child(self.render_quote("e, cx)); + } + + section.child( + h_flex() + .gap_1() + .w_full() + .child(Input::new(&self.token_input).cleanable(true)) + .child( + Button::new("receive") + .label("Redeem") + .small() + .on_click(cx.listener(move |this, _ev, window, cx| { + this.receive_token(window, cx); + })), + ), + ) + } + + fn render_quote(&self, quote: &MintQuoteEntry, cx: &Context) -> impl IntoElement { + h_flex() + .gap_1() + .w_full() + .child( + div() + .flex_1() + .min_w_0() + .truncate() + .text_color(cx.theme().muted_foreground) + .child(SharedString::from(quote.request.clone())), + ) + .child( + Clipboard::new(SharedString::from(format!("copy_{}", quote.quote_id))) + .accessibility_label("Copy lightning invoice") + .value_fn({ + let request = SharedString::from(quote.request.clone()); + move |_, _| request.clone() + }), + ) + .child( + Button::new(SharedString::from(format!("claim_{}", quote.quote_id))) + .label("Claim") + .small() + .on_click({ + let quote_id = quote.quote_id.clone(); + cx.listener(move |this, _ev, _window, cx| { + this.claim_quote(quote_id.clone(), cx); + }) + }), + ) + } + + fn render_history(&self, cx: &Context) -> impl IntoElement { + let history = WalletRegistry::global(cx).read(cx).history().to_vec(); + + let mut section = v_flex().gap_1().w_full().child( + div() + .text_xs() + .font_semibold() + .text_color(cx.theme().muted_foreground) + .child("History:"), + ); + + if history.is_empty() { + section = section.child( + div() + .text_xs() + .text_color(cx.theme().muted_foreground) + .child("No transactions yet."), + ); + } + + for entry in history { + section = section.child(self.render_history_entry(&entry, cx)); + } + + section + } + + fn render_history_entry(&self, entry: &HistoryEntry, cx: &Context) -> impl IntoElement { + let (label, color) = match entry.direction { + TransactionDirection::In => ("Received", cx.theme().success), + TransactionDirection::Out => ("Sent", cx.theme().danger), + }; + + h_flex() + .justify_between() + .w_full() + .py_0p5() + .text_xs() + .child( + h_flex() + .gap_2() + .child(div().text_color(color).child(label)) + .child(SharedString::from(format!("{} sats", entry.amount))), + ) + .child( + div() + .text_color(cx.theme().muted_foreground) + .child(entry.created_at.to_human_time()), + ) + } +} + +impl gpui_kit::component::dock::BasePanel for WalletPanel { + fn panel_name(&self) -> &'static str { + "Wallet" + } +} + +impl Panel for WalletPanel { + fn title(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + h_flex() + .gap_1p5() + .child( + Icon::new(IconName::Wallet) + .small() + .text_color(cx.theme().muted_foreground), + ) + .child(self.name.clone()) + .into_any_element() + } +} + +impl EventEmitter for WalletPanel {} + +impl Focusable for WalletPanel { + fn focus_handle(&self, _: &App) -> FocusHandle { + self.focus_handle.clone() + } +} + +impl Render for WalletPanel { + fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + let has_wallet = WalletRegistry::global(cx).read(cx).has_wallet(); + + v_flex() + .p_3() + .gap_3() + .w_full() + .overflow_y_scrollbar() + .text_xs() + .when(!has_wallet, |this| this.child(self.render_setup(cx))) + .when(has_wallet, |this| { + this.child(self.render_balance(cx)) + .child(Separator::horizontal()) + .child(self.render_send(cx)) + .child(self.render_receive(cx)) + .child(Separator::horizontal()) + .child(self.render_history(cx)) + .child(self.render_setup(cx)) + }) + } +} diff --git a/crates/workspace/Cargo.toml b/crates/workspace/Cargo.toml index 1972576c..088eef75 100644 --- a/crates/workspace/Cargo.toml +++ b/crates/workspace/Cargo.toml @@ -9,11 +9,11 @@ ui = { path = "../ui" } gpui-kit.workspace = true common = { path = "../common" } state = { path = "../state" } +nip29 = { path = "../nip29" } +nip29_ui = { path = "../nip29_ui" } nip4e = { path = "../nip4e" } chat = { path = "../chat" } chat_ui = { path = "../chat_ui" } -nip29 = { path = "../nip29" } -nip29_ui = { path = "../nip29_ui" } settings = { path = "../settings" } speech = { path = "../speech" } person = { path = "../person" } @@ -30,3 +30,7 @@ serde.workspace = true log.workspace = true rand.workspace = true smallvec.workspace = true + +[target.'cfg(not(target_arch = "wasm32"))'.dependencies] +nip60 = { path = "../nip60" } +nip60_ui = { path = "../nip60_ui" } diff --git a/crates/workspace/src/dialogs/join_group.rs b/crates/workspace/src/dialogs/join_group.rs index 8ff715f5..e96107e7 100644 --- a/crates/workspace/src/dialogs/join_group.rs +++ b/crates/workspace/src/dialogs/join_group.rs @@ -1,11 +1,11 @@ +use std::rc::Rc; + +use gpui::{App, AppContext, ClickEvent, ParentElement, Styled, Window, px}; use gpui_kit::assets::IconName; use gpui_kit::component::button::{Button, ButtonVariants}; use gpui_kit::component::input::{Input, InputState}; use gpui_kit::component::notification::Notification; use gpui_kit::component::{WindowExt, h_flex, v_flex}; -use std::rc::Rc; - -use gpui::{App, AppContext, ClickEvent, ParentElement, Styled, Window, px}; use nip29::{GroupReference, GroupsRegistry}; type SubmitFn = Rc bool>; diff --git a/crates/workspace/src/lib.rs b/crates/workspace/src/lib.rs index 3a80a0c9..eed3b675 100644 --- a/crates/workspace/src/lib.rs +++ b/crates/workspace/src/lib.rs @@ -61,6 +61,8 @@ enum Command { ShowContactList, ShowInbox, ShowBrowse, + #[cfg(not(target_arch = "wasm32"))] + ShowWallet, NewChat, NewGroup, JoinGroup, @@ -196,6 +198,30 @@ impl Workspace { }), ); + #[cfg(not(target_arch = "wasm32"))] + { + let wallet = ::nip60::WalletRegistry::global(cx); + + subscriptions.push(cx.subscribe_in( + &wallet, + window, + |_this, _wallet, event, window, cx| match event { + ::nip60::WalletEvent::Error(error) => { + window.push_notification(Notification::error(error.clone()), cx); + } + ::nip60::WalletEvent::TokenCreated(_) => { + window.push_notification( + Notification::new() + .message("Cashu token created, copy it from the wallet panel") + .with_type(NotificationType::Success), + cx, + ); + } + ::nip60::WalletEvent::Updated => {} + }, + )); + } + App::on_action(cx, { let workspace = workspace.clone(); move |action, cx| { @@ -307,6 +333,11 @@ impl Workspace { let panel = browse::init(self.dock.downgrade(), window, cx); self.add_panel_to_dock(panel, DockPlacement::Center, window, cx); } + #[cfg(not(target_arch = "wasm32"))] + Command::ShowWallet => { + let panel = nip60_ui::wallet::init(window, cx); + self.add_panel_to_dock(panel, DockPlacement::Center, window, cx); + } Command::NewChat => { new_chat::open(self.dock.downgrade(), window, cx); } diff --git a/crates/workspace/src/sidebar/list.rs b/crates/workspace/src/sidebar/list.rs index 094e03b5..8b3b5d0b 100644 --- a/crates/workspace/src/sidebar/list.rs +++ b/crates/workspace/src/sidebar/list.rs @@ -43,6 +43,19 @@ impl Sidebar { // Whether to show the create button for the contact picker let create_button = contacts_open && !self.selected_contacts.is_empty(); + #[cfg(not(target_arch = "wasm32"))] + let wallet_row = Some( + Nav::new("wallet") + .prefix(Icon::new(gpui_kit::assets::IconName::Wallet).small()) + .label("Wallet") + .text_sm() + .on_click(|_, window, cx| { + window.dispatch_action(Box::new(crate::Command::ShowWallet), cx) + }), + ); + #[cfg(target_arch = "wasm32")] + let wallet_row: Option