add nip60 wallet ui and nutzap sending
This commit is contained in:
@@ -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" ] }
|
||||
|
||||
@@ -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<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.
|
||||
pub(crate) struct History {
|
||||
pub(crate) direction: TransactionDirection,
|
||||
|
||||
+112
-2
@@ -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<keys::WalletData>,
|
||||
tokens: HashMap<EventId, TokenEvent>,
|
||||
/// Token events killed by a kind 5 delete or superseded by a `del` chain
|
||||
stale: HashSet<EventId>,
|
||||
quotes: Vec<MintQuoteEntry>,
|
||||
/// Spending history entries, newest first
|
||||
history: Vec<HistoryEntry>,
|
||||
/// NIP-61 nutzap events already claimed, recorded by our 7376 events
|
||||
redeemed: HashSet<EventId>,
|
||||
/// Nutzaps seen but not yet redeemed (wallet was not ready)
|
||||
@@ -73,6 +84,7 @@ struct LoadedState {
|
||||
wallet: Option<keys::WalletData>,
|
||||
tokens: HashMap<EventId, TokenEvent>,
|
||||
stale: HashSet<EventId>,
|
||||
history: Vec<HistoryEntry>,
|
||||
redeemed: HashSet<EventId>,
|
||||
nutzaps: Vec<Event>,
|
||||
}
|
||||
@@ -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<Self>) {
|
||||
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.
|
||||
fn primary_mint(&self, cx: &mut Context<Self>) -> Option<MintUrl> {
|
||||
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<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.
|
||||
fn absorb_nutzap(&mut self, event: Event, cx: &mut Context<Self>) {
|
||||
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,
|
||||
})
|
||||
|
||||
+174
-3
@@ -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<EventId>,
|
||||
comment: Option<String>,
|
||||
},
|
||||
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<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 } => {
|
||||
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<EventId, TokenEvent>, mint: &MintUrl) -> Vec<Ev
|
||||
.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> {
|
||||
Ok(CashuProof {
|
||||
id: proof.keyset_id.to_string(),
|
||||
|
||||
Reference in New Issue
Block a user