add nip60 wallet ui and nutzap sending
This commit is contained in:
Generated
+17
@@ -1462,6 +1462,7 @@ dependencies = [
|
|||||||
"gpui-pre",
|
"gpui-pre",
|
||||||
"instant",
|
"instant",
|
||||||
"itertools 0.13.0",
|
"itertools 0.13.0",
|
||||||
|
"nip60_ui",
|
||||||
"nostr-sdk",
|
"nostr-sdk",
|
||||||
"person",
|
"person",
|
||||||
"regex",
|
"regex",
|
||||||
@@ -6123,6 +6124,7 @@ dependencies = [
|
|||||||
"cdk-redb",
|
"cdk-redb",
|
||||||
"common",
|
"common",
|
||||||
"flume 0.11.1",
|
"flume 0.11.1",
|
||||||
|
"futures",
|
||||||
"gpui-pre",
|
"gpui-pre",
|
||||||
"gpui_tokio",
|
"gpui_tokio",
|
||||||
"hkdf",
|
"hkdf",
|
||||||
@@ -6136,6 +6138,19 @@ dependencies = [
|
|||||||
"tokio",
|
"tokio",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "nip60_ui"
|
||||||
|
version = "2.0.0-alpha3"
|
||||||
|
dependencies = [
|
||||||
|
"anyhow",
|
||||||
|
"common",
|
||||||
|
"gpui-kit",
|
||||||
|
"gpui-pre",
|
||||||
|
"nip60",
|
||||||
|
"nostr-sdk",
|
||||||
|
"smallvec",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "no_std_io2"
|
name = "no_std_io2"
|
||||||
version = "0.9.4"
|
version = "0.9.4"
|
||||||
@@ -11944,6 +11959,8 @@ dependencies = [
|
|||||||
"nip29",
|
"nip29",
|
||||||
"nip29_ui",
|
"nip29_ui",
|
||||||
"nip4e",
|
"nip4e",
|
||||||
|
"nip60",
|
||||||
|
"nip60_ui",
|
||||||
"nostr-connect",
|
"nostr-connect",
|
||||||
"nostr-sdk",
|
"nostr-sdk",
|
||||||
"person",
|
"person",
|
||||||
|
|||||||
@@ -23,3 +23,6 @@ smallvec.workspace = true
|
|||||||
serde.workspace = true
|
serde.workspace = true
|
||||||
|
|
||||||
regex = "1"
|
regex = "1"
|
||||||
|
|
||||||
|
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
|
||||||
|
nip60_ui = { path = "../nip60_ui" }
|
||||||
|
|||||||
@@ -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<Self>) {
|
||||||
|
#[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<PublicKey> = 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<Self>) {
|
fn upload(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||||
// Get the user's configured blossom server
|
// Get the user's configured blossom server
|
||||||
let server = AppSettings::get_file_server(cx);
|
let server = AppSettings::get_file_server(cx);
|
||||||
@@ -2012,6 +2047,18 @@ impl Render for ChatPanel {
|
|||||||
this.upload(window, cx);
|
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| {
|
.when(dictating, |this| {
|
||||||
this.child(
|
this.child(
|
||||||
h_flex()
|
h_flex()
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ smallvec.workspace = true
|
|||||||
log.workspace = true
|
log.workspace = true
|
||||||
flume.workspace = true
|
flume.workspace = true
|
||||||
serde_json.workspace = true
|
serde_json.workspace = true
|
||||||
|
futures.workspace = true
|
||||||
hkdf.workspace = true
|
hkdf.workspace = true
|
||||||
sha2.workspace = true
|
sha2.workspace = true
|
||||||
tokio = { version = "1", default-features = false, features = [ "rt" ] }
|
tokio = { version = "1", default-features = false, features = [ "rt" ] }
|
||||||
|
|||||||
@@ -159,6 +159,83 @@ where
|
|||||||
.await?)
|
.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<S>(
|
||||||
|
signer: &S,
|
||||||
|
me: &PublicKey,
|
||||||
|
event: &Event,
|
||||||
|
) -> Result<HistoryPayload, Error>
|
||||||
|
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<Vec<String>> = 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<S>(
|
||||||
|
signer: &S,
|
||||||
|
recipient: PublicKey,
|
||||||
|
mint: &str,
|
||||||
|
proofs: &[String],
|
||||||
|
target: Option<EventId>,
|
||||||
|
comment: &str,
|
||||||
|
) -> Result<Event, Error>
|
||||||
|
where
|
||||||
|
S: AsyncGetPublicKey + AsyncSignEvent + ?Sized,
|
||||||
|
{
|
||||||
|
let mut tags: Vec<Tag> = 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.
|
/// Encrypted 7376 history payload for one state change.
|
||||||
pub(crate) struct History {
|
pub(crate) struct History {
|
||||||
pub(crate) direction: TransactionDirection,
|
pub(crate) direction: TransactionDirection,
|
||||||
|
|||||||
+112
-2
@@ -46,12 +46,23 @@ pub struct MintQuoteEntry {
|
|||||||
pub mint: MintUrl,
|
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 {
|
pub struct WalletRegistry {
|
||||||
wallet: Option<keys::WalletData>,
|
wallet: Option<keys::WalletData>,
|
||||||
tokens: HashMap<EventId, TokenEvent>,
|
tokens: HashMap<EventId, TokenEvent>,
|
||||||
/// Token events killed by a kind 5 delete or superseded by a `del` chain
|
/// Token events killed by a kind 5 delete or superseded by a `del` chain
|
||||||
stale: HashSet<EventId>,
|
stale: HashSet<EventId>,
|
||||||
quotes: Vec<MintQuoteEntry>,
|
quotes: Vec<MintQuoteEntry>,
|
||||||
|
/// Spending history entries, newest first
|
||||||
|
history: Vec<HistoryEntry>,
|
||||||
/// NIP-61 nutzap events already claimed, recorded by our 7376 events
|
/// NIP-61 nutzap events already claimed, recorded by our 7376 events
|
||||||
redeemed: HashSet<EventId>,
|
redeemed: HashSet<EventId>,
|
||||||
/// Nutzaps seen but not yet redeemed (wallet was not ready)
|
/// Nutzaps seen but not yet redeemed (wallet was not ready)
|
||||||
@@ -73,6 +84,7 @@ struct LoadedState {
|
|||||||
wallet: Option<keys::WalletData>,
|
wallet: Option<keys::WalletData>,
|
||||||
tokens: HashMap<EventId, TokenEvent>,
|
tokens: HashMap<EventId, TokenEvent>,
|
||||||
stale: HashSet<EventId>,
|
stale: HashSet<EventId>,
|
||||||
|
history: Vec<HistoryEntry>,
|
||||||
redeemed: HashSet<EventId>,
|
redeemed: HashSet<EventId>,
|
||||||
nutzaps: Vec<Event>,
|
nutzaps: Vec<Event>,
|
||||||
}
|
}
|
||||||
@@ -103,6 +115,7 @@ impl WalletRegistry {
|
|||||||
tokens: HashMap::new(),
|
tokens: HashMap::new(),
|
||||||
stale: HashSet::new(),
|
stale: HashSet::new(),
|
||||||
quotes: Vec::new(),
|
quotes: Vec::new(),
|
||||||
|
history: Vec::new(),
|
||||||
redeemed: HashSet::new(),
|
redeemed: HashSet::new(),
|
||||||
pending_nutzaps: Vec::new(),
|
pending_nutzaps: Vec::new(),
|
||||||
redeeming: HashSet::new(),
|
redeeming: HashSet::new(),
|
||||||
@@ -135,6 +148,11 @@ impl WalletRegistry {
|
|||||||
&self.quotes
|
&self.quotes
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Spending history, newest first.
|
||||||
|
pub fn history(&self) -> &[HistoryEntry] {
|
||||||
|
&self.history
|
||||||
|
}
|
||||||
|
|
||||||
/// Create a lightning invoice that funds the wallet once paid.
|
/// Create a lightning invoice that funds the wallet once paid.
|
||||||
pub fn create_mint_quote(&mut self, amount: u64, cx: &mut Context<Self>) {
|
pub fn create_mint_quote(&mut self, amount: u64, cx: &mut Context<Self>) {
|
||||||
let Some(mint_url) = self.primary_mint(cx) else {
|
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<EventId>,
|
||||||
|
comment: Option<String>,
|
||||||
|
cx: &mut Context<Self>,
|
||||||
|
) {
|
||||||
|
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.
|
/// The first wallet mint; the UI is effectively single-mint in v1.
|
||||||
fn primary_mint(&self, cx: &mut Context<Self>) -> Option<MintUrl> {
|
fn primary_mint(&self, cx: &mut Context<Self>) -> Option<MintUrl> {
|
||||||
let Some(mint) = self.wallet.as_ref().and_then(|wallet| wallet.mints.first()) else {
|
let Some(mint) = self.wallet.as_ref().and_then(|wallet| wallet.mints.first()) else {
|
||||||
@@ -331,6 +374,7 @@ impl WalletRegistry {
|
|||||||
self.tokens.clear();
|
self.tokens.clear();
|
||||||
self.stale.clear();
|
self.stale.clear();
|
||||||
self.quotes.clear();
|
self.quotes.clear();
|
||||||
|
self.history.clear();
|
||||||
self.redeemed.clear();
|
self.redeemed.clear();
|
||||||
self.pending_nutzaps.clear();
|
self.pending_nutzaps.clear();
|
||||||
self.redeeming.clear();
|
self.redeeming.clear();
|
||||||
@@ -412,7 +456,7 @@ impl WalletRegistry {
|
|||||||
match event.kind {
|
match event.kind {
|
||||||
Kind::CashuWallet => self.absorb_wallet(event, cx),
|
Kind::CashuWallet => self.absorb_wallet(event, cx),
|
||||||
Kind::CashuWalletUnspentProof => self.absorb_token(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::EventDeletion => self.absorb_deletion(event, cx),
|
||||||
Kind::Custom(9321) => self.absorb_nutzap(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<Self>) {
|
||||||
|
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.
|
/// Claim a NIP-61 nutzap.
|
||||||
fn absorb_nutzap(&mut self, event: Event, cx: &mut Context<Self>) {
|
fn absorb_nutzap(&mut self, event: Event, cx: &mut Context<Self>) {
|
||||||
if self.redeemed.contains(&event.id) || self.redeeming.contains(&event.id) {
|
if self.redeemed.contains(&event.id) || self.redeeming.contains(&event.id) {
|
||||||
@@ -805,6 +889,11 @@ impl WalletRegistry {
|
|||||||
cx.emit(WalletEvent::TokenCreated(token));
|
cx.emit(WalletEvent::TokenCreated(token));
|
||||||
self.emit_updated(cx);
|
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 } => {
|
ops::Outcome::Received { update, amount } => {
|
||||||
self.apply_token_update(update);
|
self.apply_token_update(update);
|
||||||
self.emit_updated(cx);
|
self.emit_updated(cx);
|
||||||
@@ -836,6 +925,7 @@ impl WalletRegistry {
|
|||||||
self.wallet = state.wallet;
|
self.wallet = state.wallet;
|
||||||
self.tokens = state.tokens;
|
self.tokens = state.tokens;
|
||||||
self.stale = state.stale;
|
self.stale = state.stale;
|
||||||
|
self.history = state.history;
|
||||||
self.redeemed = state.redeemed;
|
self.redeemed = state.redeemed;
|
||||||
cx.notify();
|
cx.notify();
|
||||||
self.ensure_wallets(cx);
|
self.ensure_wallets(cx);
|
||||||
@@ -996,12 +1086,31 @@ impl LoadedState {
|
|||||||
|
|
||||||
// Nutzap events tagged redeemed in our history events are already claimed
|
// Nutzap events tagged redeemed in our history events are already claimed
|
||||||
let mut redeemed = HashSet::new();
|
let mut redeemed = HashSet::new();
|
||||||
for event in history_events {
|
for event in &history_events {
|
||||||
for id in redeemed_ids(event) {
|
for id in redeemed_ids(event) {
|
||||||
redeemed.insert(id);
|
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
|
let nutzaps = nutzap_events
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.filter(|event| !redeemed.contains(&event.id))
|
.filter(|event| !redeemed.contains(&event.id))
|
||||||
@@ -1063,6 +1172,7 @@ impl LoadedState {
|
|||||||
wallet,
|
wallet,
|
||||||
tokens,
|
tokens,
|
||||||
stale,
|
stale,
|
||||||
|
history,
|
||||||
redeemed,
|
redeemed,
|
||||||
nutzaps,
|
nutzaps,
|
||||||
})
|
})
|
||||||
|
|||||||
+174
-3
@@ -1,5 +1,6 @@
|
|||||||
use std::collections::{HashMap, HashSet};
|
use std::collections::{HashMap, HashSet};
|
||||||
use std::str::FromStr;
|
use std::str::FromStr;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
use anyhow::{Error, anyhow};
|
use anyhow::{Error, anyhow};
|
||||||
use cdk::Amount;
|
use cdk::Amount;
|
||||||
@@ -7,17 +8,21 @@ use cdk::amount::SplitTarget;
|
|||||||
use cdk::mint_url::MintUrl;
|
use cdk::mint_url::MintUrl;
|
||||||
use cdk::nuts::nut00::KnownMethod;
|
use cdk::nuts::nut00::KnownMethod;
|
||||||
use cdk::nuts::{
|
use cdk::nuts::{
|
||||||
CurrencyUnit, Id, MeltQuoteState, MintQuoteState, PaymentMethod, Proof, Proofs, SecretKey,
|
CurrencyUnit, Id, KeySetInfo, MeltQuoteState, MintQuoteState, PaymentMethod, Proof, Proofs,
|
||||||
State,
|
SecretKey, SpendingConditions, State,
|
||||||
};
|
};
|
||||||
use cdk::secret::Secret;
|
use cdk::secret::Secret;
|
||||||
use cdk::wallet::types::ProofInfo;
|
use cdk::wallet::types::ProofInfo;
|
||||||
use cdk::wallet::{ReceiveOptions, SendOptions, Wallet};
|
use cdk::wallet::{ReceiveOptions, SendOptions, Wallet};
|
||||||
|
use futures::StreamExt;
|
||||||
use nostr::nips::nip60::{CashuProof, TokenEvent, TransactionDirection};
|
use nostr::nips::nip60::{CashuProof, TokenEvent, TransactionDirection};
|
||||||
use nostr_sdk::prelude::*;
|
use nostr_sdk::prelude::*;
|
||||||
|
|
||||||
use crate::keys;
|
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.
|
/// A job mutating the wallet store or reconciling it with relays.
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub(crate) enum Job {
|
pub(crate) enum Job {
|
||||||
@@ -38,6 +43,12 @@ pub(crate) enum Job {
|
|||||||
mint: MintUrl,
|
mint: MintUrl,
|
||||||
amount: u64,
|
amount: u64,
|
||||||
},
|
},
|
||||||
|
SendNutzap {
|
||||||
|
recipient: PublicKey,
|
||||||
|
amount: u64,
|
||||||
|
target: Option<EventId>,
|
||||||
|
comment: Option<String>,
|
||||||
|
},
|
||||||
Receive {
|
Receive {
|
||||||
mint: MintUrl,
|
mint: MintUrl,
|
||||||
token: String,
|
token: String,
|
||||||
@@ -167,6 +178,74 @@ impl Job {
|
|||||||
|
|
||||||
Ok(Outcome::Sent { update, token })
|
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<KeySetInfo> = 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 } => {
|
Job::Receive { mint, token } => {
|
||||||
let wallet = ctx.wallet(&mint)?;
|
let wallet = ctx.wallet(&mint)?;
|
||||||
let received = wallet.receive(&token, ReceiveOptions::default()).await?;
|
let received = wallet.receive(&token, ReceiveOptions::default()).await?;
|
||||||
@@ -259,6 +338,10 @@ pub(crate) enum Outcome {
|
|||||||
update: TokenUpdate,
|
update: TokenUpdate,
|
||||||
token: String,
|
token: String,
|
||||||
},
|
},
|
||||||
|
NutzapSent {
|
||||||
|
update: TokenUpdate,
|
||||||
|
amount: u64,
|
||||||
|
},
|
||||||
Received {
|
Received {
|
||||||
update: TokenUpdate,
|
update: TokenUpdate,
|
||||||
amount: u64,
|
amount: u64,
|
||||||
@@ -339,7 +422,9 @@ where
|
|||||||
let mut updates = Vec::new();
|
let mut updates = Vec::new();
|
||||||
|
|
||||||
for (mint_url, wallet) in &self.wallets {
|
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?;
|
let minted = wallet.mint_unissued_quotes().await?;
|
||||||
if minted == Amount::ZERO {
|
if minted == Amount::ZERO {
|
||||||
@@ -414,6 +499,92 @@ fn previous_ids(tokens: &HashMap<EventId, TokenEvent>, mint: &MintUrl) -> Vec<Ev
|
|||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Resolve a recipient's 10019 wallet info and pick a mint we share with them.
|
||||||
|
async fn recipient_wallet_info<S>(
|
||||||
|
ctx: &JobCtx<S>,
|
||||||
|
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<Event> = 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<String>), 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<CashuProof, Error> {
|
fn cashu_proof(proof: &Proof) -> Result<CashuProof, Error> {
|
||||||
Ok(CashuProof {
|
Ok(CashuProof {
|
||||||
id: proof.keyset_id.to_string(),
|
id: proof.keyset_id.to_string(),
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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<dyn Fn(&ClickEvent, &mut Window, &mut App) -> 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<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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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<WalletPanel> {
|
||||||
|
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<InputState>,
|
||||||
|
/// Amount input for sending a cashu token
|
||||||
|
send_input: Entity<InputState>,
|
||||||
|
/// Amount input for the funding invoice
|
||||||
|
invoice_input: Entity<InputState>,
|
||||||
|
/// Pasted token input for receiving ecash
|
||||||
|
token_input: Entity<InputState>,
|
||||||
|
/// The token produced by the last send, shown for copying
|
||||||
|
sent_token: Option<SharedString>,
|
||||||
|
|
||||||
|
_subscriptions: SmallVec<[Subscription; 1]>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl WalletPanel {
|
||||||
|
fn new(window: &mut Window, cx: &mut Context<Self>) -> 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<InputState>, cx: &Context<Self>) -> Option<u64> {
|
||||||
|
match input.read(cx).value().trim().parse::<u64>() {
|
||||||
|
Ok(amount) if amount > 0 => Some(amount),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn setup(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||||
|
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<Self>) {
|
||||||
|
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<Self>) {
|
||||||
|
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<Self>) {
|
||||||
|
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<Self>) {
|
||||||
|
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<Self>) -> 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<Self>) -> 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<Self>) -> 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<Self>) -> 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<Self>) -> 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<Self>) -> 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<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()),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 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))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,11 +9,11 @@ ui = { path = "../ui" }
|
|||||||
gpui-kit.workspace = true
|
gpui-kit.workspace = true
|
||||||
common = { path = "../common" }
|
common = { path = "../common" }
|
||||||
state = { path = "../state" }
|
state = { path = "../state" }
|
||||||
|
nip29 = { path = "../nip29" }
|
||||||
|
nip29_ui = { path = "../nip29_ui" }
|
||||||
nip4e = { path = "../nip4e" }
|
nip4e = { path = "../nip4e" }
|
||||||
chat = { path = "../chat" }
|
chat = { path = "../chat" }
|
||||||
chat_ui = { path = "../chat_ui" }
|
chat_ui = { path = "../chat_ui" }
|
||||||
nip29 = { path = "../nip29" }
|
|
||||||
nip29_ui = { path = "../nip29_ui" }
|
|
||||||
settings = { path = "../settings" }
|
settings = { path = "../settings" }
|
||||||
speech = { path = "../speech" }
|
speech = { path = "../speech" }
|
||||||
person = { path = "../person" }
|
person = { path = "../person" }
|
||||||
@@ -30,3 +30,7 @@ serde.workspace = true
|
|||||||
log.workspace = true
|
log.workspace = true
|
||||||
rand.workspace = true
|
rand.workspace = true
|
||||||
smallvec.workspace = true
|
smallvec.workspace = true
|
||||||
|
|
||||||
|
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
|
||||||
|
nip60 = { path = "../nip60" }
|
||||||
|
nip60_ui = { path = "../nip60_ui" }
|
||||||
|
|||||||
@@ -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::assets::IconName;
|
||||||
use gpui_kit::component::button::{Button, ButtonVariants};
|
use gpui_kit::component::button::{Button, ButtonVariants};
|
||||||
use gpui_kit::component::input::{Input, InputState};
|
use gpui_kit::component::input::{Input, InputState};
|
||||||
use gpui_kit::component::notification::Notification;
|
use gpui_kit::component::notification::Notification;
|
||||||
use gpui_kit::component::{WindowExt, h_flex, v_flex};
|
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};
|
use nip29::{GroupReference, GroupsRegistry};
|
||||||
|
|
||||||
type SubmitFn = Rc<dyn Fn(&ClickEvent, &mut Window, &mut App) -> bool>;
|
type SubmitFn = Rc<dyn Fn(&ClickEvent, &mut Window, &mut App) -> bool>;
|
||||||
|
|||||||
@@ -61,6 +61,8 @@ enum Command {
|
|||||||
ShowContactList,
|
ShowContactList,
|
||||||
ShowInbox,
|
ShowInbox,
|
||||||
ShowBrowse,
|
ShowBrowse,
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
|
ShowWallet,
|
||||||
NewChat,
|
NewChat,
|
||||||
NewGroup,
|
NewGroup,
|
||||||
JoinGroup,
|
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, {
|
App::on_action(cx, {
|
||||||
let workspace = workspace.clone();
|
let workspace = workspace.clone();
|
||||||
move |action, cx| {
|
move |action, cx| {
|
||||||
@@ -307,6 +333,11 @@ impl Workspace {
|
|||||||
let panel = browse::init(self.dock.downgrade(), window, cx);
|
let panel = browse::init(self.dock.downgrade(), window, cx);
|
||||||
self.add_panel_to_dock(panel, DockPlacement::Center, 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 => {
|
Command::NewChat => {
|
||||||
new_chat::open(self.dock.downgrade(), window, cx);
|
new_chat::open(self.dock.downgrade(), window, cx);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -43,6 +43,19 @@ impl Sidebar {
|
|||||||
// Whether to show the create button for the contact picker
|
// Whether to show the create button for the contact picker
|
||||||
let create_button = contacts_open && !self.selected_contacts.is_empty();
|
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<Nav> = None;
|
||||||
|
|
||||||
let (list_id, title) = if contacts_open {
|
let (list_id, title) = if contacts_open {
|
||||||
("sidebar-contacts", Some("Contacts"))
|
("sidebar-contacts", Some("Contacts"))
|
||||||
} else if requests_open {
|
} else if requests_open {
|
||||||
@@ -75,6 +88,7 @@ impl Sidebar {
|
|||||||
window.dispatch_action(Box::new(Command::ShowBrowse), cx)
|
window.dispatch_action(Box::new(Command::ShowBrowse), cx)
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
.when_some(wallet_row, |this, row| this.child(row))
|
||||||
.child(
|
.child(
|
||||||
Nav::new("reqs")
|
Nav::new("reqs")
|
||||||
.prefix(Icon::new(IconName::Invite).small())
|
.prefix(Icon::new(IconName::Invite).small())
|
||||||
|
|||||||
+32
-6
@@ -312,7 +312,10 @@ handling, struct/impl blocks over standalone functions. Applied:
|
|||||||
collapsed the mint-resolution boilerplate shared by quote/melt/send
|
collapsed the mint-resolution boilerplate shared by quote/melt/send
|
||||||
into `primary_mint`, and `MintQuoteEntry.mint` is now a `cdk::MintUrl`.
|
into `primary_mint`, and `MintQuoteEntry.mint` is now a `cdk::MintUrl`.
|
||||||
- Reconcile now calls `recover_incomplete_sagas` (the plan promised it;
|
- Reconcile now calls `recover_incomplete_sagas` (the plan promised it;
|
||||||
it was missing), before `mint_unissued_quotes`.
|
it was missing), before `mint_unissued_quotes`. It is best-effort: the
|
||||||
|
cdk-redb fresh-store init does not create the `wallet_sagas` table
|
||||||
|
(upstream bug), so the first recovery attempts fail with "table does
|
||||||
|
not exist" until a saga is written — logged, never fatal.
|
||||||
|
|
||||||
Not changed, deliberately: per-outcome `Outcome` variants, the bounded
|
Not changed, deliberately: per-outcome `Outcome` variants, the bounded
|
||||||
notification dedup set, the single queued Reconcile collapse, and the
|
notification dedup set, the single queued Reconcile collapse, and the
|
||||||
@@ -320,12 +323,35 @@ remaining small conversion helpers in `ops.rs` (`cashu_proof`,
|
|||||||
`proof_info`, `nostr_mint`) — they are simple, and further abstraction
|
`proof_info`, `nostr_mint`) — they are simple, and further abstraction
|
||||||
would be over-engineering.
|
would be over-engineering.
|
||||||
|
|
||||||
### Phase 4 — UI
|
### Phase 4 — UI (done)
|
||||||
|
|
||||||
- Settings entry for the mint URL (`settings` crate pattern).
|
Implemented in a new `crates/nip60_ui` (crate-level wasm gate, so it
|
||||||
- Minimal wallet surface in the workspace (balance, receive invoice,
|
compiles to nothing on web; consumers list it under
|
||||||
paste-token receive, send-token, history list) subscribing to
|
`[target.'cfg(not(target_arch = "wasm32"))'.dependencies]`):
|
||||||
`WalletEvent`. Follows `nip29_ui`'s split if it grows beyond a panel.
|
|
||||||
|
- Backend additions: `WalletRegistry::history()` (`HistoryEntry` list,
|
||||||
|
newest first, from live + cached `7376`s), and `send_nutzap(recipient,
|
||||||
|
amount, target, comment)` — `Job::SendNutzap` fetches the recipient's
|
||||||
|
`10019` from the local database (falling back to `stream_events`),
|
||||||
|
picks the first shared mint, P2PK-locks a `prepare_send` to their
|
||||||
|
wallet pubkey, publishes a public `9321` (`p`/`u`/`unit`/`proof` tags,
|
||||||
|
optional `e`), then runs the usual sync tail with an out-history.
|
||||||
|
- `wallet.rs` — `WalletPanel` in the left dock: balance, setup/add-mint,
|
||||||
|
send-token (token shown for copy), receive (invoice per quote with
|
||||||
|
copy + claim, paste-token redeem), history list. Subscribes to
|
||||||
|
`WalletEvent`; errors surface as notifications here and, via a
|
||||||
|
workspace-level subscription, even when the panel is closed.
|
||||||
|
- `lib.rs` — `open_nutzap` dialog (amount prompt) reused by chat.
|
||||||
|
- Sidebar: "Wallet" row under Browse (gated off on wasm), dispatching
|
||||||
|
`Command::ShowWallet` -> dock the panel.
|
||||||
|
- `chat_ui`: a coins button in the composer opens the nutzap dialog for
|
||||||
|
the room's peer; group rooms are declined with a warning (v1 is
|
||||||
|
direct-chat only).
|
||||||
|
- `nip29_ui`: "Send Nutzap" context-menu item on messages, nutzapping
|
||||||
|
the author with an `e` tag on the message.
|
||||||
|
|
||||||
|
Not rendered yet: nutzaps/tokens do not appear in the chat timeline; the
|
||||||
|
wallet panel history is the only transaction view.
|
||||||
|
|
||||||
## Tests
|
## Tests
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user