feat: add support for cashu wallet (nip60 & nip61) (#71)

Reviewed-on: #71
This commit was merged in pull request #71.
This commit is contained in:
2026-11-09 01:12:27 +00:00
parent c1757bbcae
commit 98a12e438a
20 changed files with 4732 additions and 15 deletions
+19
View File
@@ -0,0 +1,19 @@
[package]
name = "nip60_ui"
version.workspace = true
edition.workspace = true
publish.workspace = true
[dependencies]
common = { path = "../common" }
nip60 = { path = "../nip60" }
person = { path = "../person" }
settings = { path = "../settings" }
state = { path = "../state" }
ui = { path = "../ui" }
gpui.workspace = true
gpui-kit.workspace = true
nostr-sdk.workspace = true
anyhow.workspace = true
smallvec.workspace = true
+77
View File
@@ -0,0 +1,77 @@
#![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<dyn Fn(&ClickEvent, &mut Window, &mut App) -> bool>;
pub fn open_nutzap(
window: &mut Window,
cx: &mut App,
recipient: PublicKey,
target: Option<EventId>,
) {
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::<u64>() {
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);
}
}
})),
),
)
});
}
+787
View File
@@ -0,0 +1,787 @@
use std::ops::Range;
use std::rc::Rc;
use ::nip60::{HistoryEntry, WalletEvent, WalletRegistry};
use common::TimestampExt;
use gpui::prelude::FluentBuilder;
use gpui::{
AnyElement, App, AppContext, ClickEvent, Context, Entity, EventEmitter, FocusHandle, Focusable,
InteractiveElement as _, IntoElement, ParentElement, Render, SharedString,
StatefulInteractiveElement as _, Styled, Subscription, Task, Window, div, px, relative,
uniform_list,
};
use gpui_kit::assets::IconName;
use gpui_kit::base::StyledExt as _;
use gpui_kit::component::button::{Button, ButtonVariants as _};
use gpui_kit::component::dock::{Panel, PanelEvent};
use gpui_kit::component::input::{Input, InputEvent, InputState, NumberInput};
use gpui_kit::component::notification::Notification;
use gpui_kit::component::separator::Separator;
use gpui_kit::component::{
ActiveTheme, Disableable as _, Icon, Sizable, WindowExt, h_flex, v_flex,
};
use nostr_sdk::prelude::*;
use person::Directory;
use settings::AppSettings;
use smallvec::SmallVec;
use state::NostrRegistry;
use ui::Avatar;
pub fn init(window: &mut Window, cx: &mut App) -> Entity<WalletPanel> {
cx.new(|cx| WalletPanel::new(window, cx))
}
type SubmitFn = Rc<dyn Fn(&ClickEvent, &mut Window, &mut App) -> bool>;
pub struct WalletPanel {
name: SharedString,
focus_handle: FocusHandle,
_subscriptions: SmallVec<[Subscription; 1]>,
}
impl WalletPanel {
fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
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(_) => {}
},
));
Self {
name: "Wallet".into(),
focus_handle: cx.focus_handle(),
_subscriptions: subscriptions,
}
}
fn render_balance(&self, cx: &Context<Self>) -> impl IntoElement {
let balance = WalletRegistry::global(cx).read(cx).balance();
h_flex()
.justify_center()
.items_baseline()
.gap_2()
.child(
div()
.text_3xl()
.line_height(relative(1.0))
.child(SharedString::from(balance.to_string())),
)
.child(
div()
.italic()
.line_height(relative(1.0))
.text_color(cx.theme().muted_foreground)
.child("sats"),
)
}
fn render_actions(&self) -> impl IntoElement {
h_flex().w_full().justify_center().child(
h_flex()
.w_80()
.gap_2()
.child(
Button::new("send")
.label("Send")
.secondary()
.rounded_full()
.flex_1()
.on_click(|_, window, cx| open_send_dialog(window, cx)),
)
.child(
Button::new("receive")
.label("Receive")
.secondary()
.rounded_full()
.flex_1()
.on_click(|_, window, cx| open_receive_dialog(window, cx)),
),
)
}
fn render_history(
&mut self,
history: Vec<HistoryEntry>,
cx: &mut Context<Self>,
) -> impl IntoElement {
if history.is_empty() {
return v_flex()
.items_center()
.justify_center()
.h_32()
.w_full()
.text_xs()
.text_color(cx.theme().muted_foreground)
.border_1()
.border_dashed()
.border_color(cx.theme().border)
.rounded(cx.theme().radius_lg)
.child("No transactions yet.")
.into_any_element();
}
uniform_list(
"wallet-history",
history.len(),
cx.processor(move |this, range, _window, cx| {
this.render_history_rows(range, &history, cx)
}),
)
.flex_1()
.min_h_0()
.into_any_element()
}
fn render_history_rows(
&self,
range: std::ops::Range<usize>,
history: &[HistoryEntry],
cx: &Context<Self>,
) -> Vec<AnyElement> {
range
.map(|ix| {
self.render_history_entry(&history[ix], cx)
.into_any_element()
})
.collect()
}
fn render_history_entry(&self, entry: &HistoryEntry, cx: &Context<Self>) -> 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()),
)
}
}
/// Pick a contact and an amount, then send a NIP-61 nutzap
fn open_send_dialog(window: &mut Window, cx: &mut App) {
let view = cx.new(|cx| SendDialog::new(window, cx));
window.open_dialog(cx, {
let view = view.clone();
move |this, _window, _cx| this.width(px(420.)).title("Send eCash").child(view.clone())
});
}
struct SendDialog {
/// Search input, filters contacts or accepts an npub / NIP-05 address
input: Entity<InputState>,
amount_input: Entity<InputState>,
/// Current user's contacts
contacts: Option<Vec<PublicKey>>,
/// Person directory for contact names and avatars
directory: Entity<Directory>,
/// The contact chosen to receive the nutzap
selected: Option<PublicKey>,
/// Whether a NIP-05 address is being resolved
resolving: bool,
error: Option<SharedString>,
tasks: Vec<Task<Result<(), anyhow::Error>>>,
_subscriptions: Vec<Subscription>,
}
impl SendDialog {
fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
let input = cx.new(|cx| {
InputState::new(window, cx).placeholder("Search or enter npub or user@address.me")
});
let amount_input = cx.new(|cx| {
InputState::new(window, cx)
.placeholder("Amount (sats)")
.step(1.)
.min(1.)
});
let subscriptions = vec![
cx.subscribe_in(&input, window, |this, _input, event, window, cx| {
if let InputEvent::PressEnter { .. } = event {
this.submit(window, cx);
}
}),
cx.subscribe_in(&amount_input, window, |this, _input, event, window, cx| {
if let InputEvent::PressEnter { .. } = event {
this.send(window, cx);
}
}),
];
// Load the contacts once the view is mounted
cx.defer_in(window, |this, window, cx| {
this.load_contacts(window, cx);
});
Self {
input,
amount_input,
contacts: None,
directory: Directory::new(cx),
selected: None,
resolving: false,
error: None,
tasks: Vec::new(),
_subscriptions: subscriptions,
}
}
/// Load the signed-in user's contact list.
fn load_contacts(&mut self, window: &mut Window, cx: &mut Context<Self>) {
if self.contacts.is_some() {
return;
}
let nostr = NostrRegistry::global(cx);
let client = nostr.read(cx).client();
let Some(current_user) = nostr.read(cx).current_user() else {
return;
};
let task: Task<Result<Vec<PublicKey>, anyhow::Error>> = cx.background_spawn(async move {
let filter = Filter::new()
.author(current_user)
.kind(Kind::ContactList)
.limit(1);
let contacts: Vec<PublicKey> = client
.database()
.query(filter)
.await?
.into_iter()
.next()
.map(|event| event.tags.public_keys().collect())
.unwrap_or_default();
Ok(contacts)
});
self.tasks.push(cx.spawn_in(window, async move |this, cx| {
let contacts = task.await;
this.update_in(cx, |this, _window, cx| {
match contacts {
Ok(contacts) => {
this.directory.update(cx, |directory, cx| {
directory.track(contacts.iter().copied(), cx)
});
this.contacts = Some(contacts);
}
Err(error) => this.set_error(error.to_string(), cx),
}
cx.notify();
})?;
Ok(())
}));
}
/// The contacts matching the current input, or every contact when empty.
fn filtered_contacts(&self, cx: &Context<Self>) -> Vec<PublicKey> {
let Some(contacts) = self.contacts.as_ref() else {
return Vec::new();
};
let query = self.input.read(cx).value().trim().to_lowercase();
if query.is_empty() {
return contacts.clone();
}
contacts
.iter()
.filter(|public_key| {
if public_key.to_hex().to_lowercase().contains(&query) {
return true;
}
let name = self.directory.read(cx).person(public_key, cx).name();
name.to_lowercase().contains(&query)
})
.cloned()
.collect()
}
/// Resolve the input into a contact selection.
fn submit(&mut self, window: &mut Window, cx: &mut Context<Self>) {
if self.resolving {
return;
}
let query = self.input.read(cx).value().trim().to_string();
if query.is_empty() {
return;
}
// A raw public key or an npub can be selected directly.
if let Ok(public_key) = PublicKey::parse(&query) {
self.select(public_key, cx);
self.clear_input(window, cx);
return;
}
// A NIP-05 address is resolved before being selected.
if let Ok(address) = Nip05Address::parse(&query) {
let nostr = NostrRegistry::global(cx);
let task = nostr.read(cx).query_address(address, cx);
self.set_resolving(true, window, cx);
self.tasks.push(cx.spawn_in(window, async move |this, cx| {
let result = task.await;
this.update_in(cx, |this, window, cx| {
this.set_resolving(false, window, cx);
match result {
Ok(public_key) => {
this.select(public_key, cx);
this.clear_input(window, cx);
}
Err(error) => this.set_error(error.to_string(), cx),
}
})?;
Ok(())
}));
return;
}
// Fall back to a local search by contact name.
let matches = self.filtered_contacts(cx);
match matches.len() {
0 => self.set_error("Enter a valid npub or NIP-05 address", cx),
1 => {
let public_key = matches[0];
self.select(public_key, cx);
self.clear_input(window, cx);
}
_ => self.set_error("Multiple contacts match, pick one from the list", cx),
}
}
/// Select a single recipient for the nutzap.
fn select(&mut self, public_key: PublicKey, cx: &mut Context<Self>) {
let nostr = NostrRegistry::global(cx);
if nostr.read(cx).current_user() == Some(public_key) {
self.set_error("You cannot send to yourself", cx);
return;
}
self.directory
.update(cx, |directory, cx| directory.track([public_key], cx));
self.selected = Some(public_key);
self.error = None;
cx.notify();
}
/// Toggle a contact's selection.
fn toggle_selection(&mut self, public_key: &PublicKey, cx: &mut Context<Self>) {
if self.selected == Some(*public_key) {
self.selected = None;
cx.notify();
} else {
self.select(*public_key, cx);
}
}
/// Validate the selection and amount, then schedule the nutzap.
fn send(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> bool {
let Some(recipient) = self.selected else {
self.set_error("Select a contact to send to", cx);
return false;
};
let Some(amount) = parse_amount(&self.amount_input, cx) else {
self.set_error("Enter an amount in sats", cx);
return false;
};
WalletRegistry::global(cx).update(cx, |registry, cx| {
registry.send_nutzap(recipient, amount, None, None, cx);
});
true
}
fn clear_input(&mut self, window: &mut Window, cx: &mut Context<Self>) {
self.input.update(cx, |this, cx| {
this.set_value("", window, cx);
});
}
/// Toggle the input's loading spinner while a NIP-05 address is resolved.
fn set_resolving(&mut self, resolving: bool, window: &mut Window, cx: &mut Context<Self>) {
self.input.update(cx, |this, cx| {
this.set_loading(resolving, window, cx);
});
self.resolving = resolving;
cx.notify();
}
fn set_error(&mut self, message: impl Into<SharedString>, cx: &mut Context<Self>) {
self.error = Some(message.into());
cx.notify();
}
fn render_contacts(
&self,
range: Range<usize>,
contacts: &[PublicKey],
cx: &mut Context<Self>,
) -> Vec<AnyElement> {
contacts
.get(range)
.into_iter()
.flatten()
.map(|public_key| {
let profile = self.directory.read(cx).person(public_key, cx);
let public_key = *public_key;
let selected = self.selected == Some(public_key);
let id = SharedString::from(format!("contact-{}", public_key.to_hex()));
h_flex()
.id(id)
.w_full()
.h_8()
.px_2()
.gap_2()
.rounded(cx.theme().radius)
.text_sm()
.cursor_pointer()
.hover(|this| this.bg(cx.theme().list_hover))
.when(selected, |this| this.bg(cx.theme().list_active))
.when(!AppSettings::get_hide_avatar(cx), |this| {
this.child(
Avatar::new(profile.avatar())
.seed(profile.avatar_seed())
.small(),
)
})
.child(div().min_w_0().truncate().child(profile.name()))
.on_click(cx.listener(move |this, _event, _window, cx| {
this.toggle_selection(&public_key, cx);
}))
.into_any_element()
})
.collect()
}
fn render_selected(&self, cx: &mut Context<Self>) -> impl IntoElement {
let Some(public_key) = self.selected else {
return div().into_any_element();
};
let profile = self.directory.read(cx).person(&public_key, cx);
let id = SharedString::from(format!("selected-{}", public_key.to_hex()));
h_flex()
.gap_1()
.child(
h_flex()
.id(id)
.gap_1()
.p_0p5()
.rounded_full()
.bg(cx.theme().secondary)
.hover(|this| this.bg(cx.theme().primary))
.child(
Avatar::new(profile.avatar())
.seed(profile.avatar_seed())
.xsmall(),
)
.child(
div()
.max_w_32()
.truncate()
.text_xs()
.line_height(relative(1.0))
.child(profile.name()),
)
.on_click(cx.listener(move |this, _event, _window, cx| {
this.selected = None;
cx.notify();
})),
)
.into_any_element()
}
}
impl Render for SendDialog {
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
const NOTICE: &str = "Recipients need a NIP-60 wallet on a mint you both use.";
let contacts = Rc::new(self.filtered_contacts(cx));
let contact_count = contacts.len();
v_flex()
.gap_3()
.child(NumberInput::new(&self.amount_input))
.child(
h_flex()
.gap_1()
.w_full()
.child(Input::new(&self.input).flex_1().cleanable(true))
.child(
Button::new("add")
.icon(IconName::Plus)
.tooltip("Add person")
.ghost()
.large()
.disabled(self.resolving)
.on_click(cx.listener(|this, _event, window, cx| {
this.submit(window, cx);
})),
),
)
.when(self.selected.is_some(), |this| {
this.child(self.render_selected(cx))
})
.child(
uniform_list(
"send-contacts",
contact_count,
cx.processor(move |this, range, _window, cx| {
this.render_contacts(range, contacts.as_slice(), cx)
}),
)
.h_64(),
)
.map(|this| {
if let Some(error) = self.error.clone() {
this.child(div().text_xs().text_color(cx.theme().danger).child(error))
} else {
this.child(
div()
.text_xs()
.text_color(cx.theme().muted_foreground)
.child(NOTICE),
)
}
})
.child(
h_flex()
.gap_1()
.justify_end()
.child(
Button::new("cancel")
.label("Cancel")
.secondary()
.on_click(|_, window, cx| window.close_dialog(cx)),
)
.child(
Button::new("send")
.label("Send")
.primary()
.on_click(cx.listener(move |this, _event, window, cx| {
if this.send(window, cx) {
window.close_dialog(cx);
}
})),
),
)
}
}
/// Create a lightning invoice or redeem a pasted cashu token
fn open_receive_dialog(window: &mut Window, cx: &mut App) {
let amount_input = cx.new(|cx| InputState::new(window, cx).placeholder("Amount (sats)"));
let token_input = cx.new(|cx| InputState::new(window, cx).placeholder("cashu…"));
let create_invoice: SubmitFn = {
let wallet = WalletRegistry::global(cx);
let input = amount_input.clone();
Rc::new(move |_, window, cx| {
let Some(amount) = parse_amount(&input, cx) else {
window.push_notification(Notification::error("Enter an amount in sats"), cx);
return false;
};
wallet.update(cx, |registry, cx| {
registry.create_mint_quote(amount, cx);
});
true
})
};
let redeem: SubmitFn = {
let wallet = WalletRegistry::global(cx);
let input = token_input.clone();
Rc::new(move |_, window, cx| {
let token = input.read(cx).value().trim().to_string();
if token.is_empty() {
window.push_notification(Notification::error("Paste a cashu token"), cx);
return false;
}
wallet.update(cx, |registry, cx| {
registry.receive_token(token, cx);
});
true
})
};
window.open_dialog(cx, move |this, _window, _cx| {
this.width(px(420.))
.title("Receive")
.on_ok({
let token_input = token_input.clone();
let create_invoice = create_invoice.clone();
let redeem = redeem.clone();
move |_event, window, cx| {
if !token_input.read(cx).value().trim().is_empty() {
redeem(_event, window, cx)
} else {
create_invoice(_event, window, cx)
}
}
})
.child(
v_flex()
.gap_3()
.child(
v_flex()
.gap_1p5()
.child(div().text_xs().font_semibold().child("Lightning invoice:"))
.child(
h_flex()
.gap_1()
.w_full()
.child(Input::new(&amount_input).cleanable(true))
.child(
Button::new("invoice")
.label("Invoice")
.secondary()
.w_24()
.on_click({
let create_invoice = create_invoice.clone();
move |event, window, cx| {
if create_invoice(event, window, cx) {
window.close_dialog(cx);
}
}
}),
),
),
)
.child(Separator::horizontal())
.child(
v_flex()
.gap_1p5()
.child(div().text_xs().font_semibold().child("Cashu token:"))
.child(
h_flex()
.gap_1()
.w_full()
.child(Input::new(&token_input).cleanable(true))
.child(
Button::new("redeem")
.label("Redeem")
.secondary()
.w_24()
.on_click({
let redeem = redeem.clone();
move |event, window, cx| {
if redeem(event, window, cx) {
window.close_dialog(cx);
}
}
}),
),
),
),
)
});
}
fn parse_amount(input: &Entity<InputState>, cx: &App) -> Option<u64> {
match input.read(cx).value().trim().parse::<u64>() {
Ok(amount) if amount > 0 => Some(amount),
_ => None,
}
}
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<Self>) -> 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<PanelEvent> 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<Self>) -> impl IntoElement {
let history = WalletRegistry::global(cx).read(cx).history().to_vec();
v_flex()
.size_full()
.p_3()
.gap_4()
.text_xs()
.child(self.render_balance(cx))
.child(self.render_actions())
.child(
v_flex()
.gap_2()
.child(
div()
.text_xs()
.font_semibold()
.text_color(cx.theme().muted_foreground)
.child("History"),
)
.child(self.render_history(history, cx)),
)
}
}