add nip60 wallet ui and nutzap sending

This commit is contained in:
2026-11-06 18:04:31 +07:00
parent 2d85c563fa
commit 53aa54a034
15 changed files with 1033 additions and 16 deletions
+174 -3
View File
@@ -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(),