implement nip61 nutzap redemption

This commit is contained in:
2026-11-06 09:08:44 +07:00
parent 4b9e229532
commit 8c5b816ebf
7 changed files with 437 additions and 52 deletions
Generated
-14
View File
@@ -1329,19 +1329,6 @@ dependencies = [
"web-sys", "web-sys",
] ]
[[package]]
name = "cdk-nostr"
version = "0.18.0"
source = "git+https://github.com/cashubtc/cdk#578e719fbf683871a8480961627700f01f3ee7ee"
dependencies = [
"nostr",
"nostr-sdk",
"thiserror 2.0.21",
"tokio",
"tokio-util",
"tracing",
]
[[package]] [[package]]
name = "cdk-redb" name = "cdk-redb"
version = "0.18.0" version = "0.18.0"
@@ -6133,7 +6120,6 @@ version = "2.0.0-alpha3"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"cdk", "cdk",
"cdk-nostr",
"cdk-redb", "cdk-redb",
"common", "common",
"flume 0.11.1", "flume 0.11.1",
-1
View File
@@ -54,7 +54,6 @@ rand = { version = "0.10", default-features = false, features = [ "std", "sys_rn
# storage: pure Rust, avoids the libsqlite3-sys links clash with nostr-sqlite. # storage: pure Rust, avoids the libsqlite3-sys links clash with nostr-sqlite.
cdk = { git = "https://github.com/cashubtc/cdk", default-features = false, features = [ "wallet" ] } cdk = { git = "https://github.com/cashubtc/cdk", default-features = false, features = [ "wallet" ] }
cdk-redb = { git = "https://github.com/cashubtc/cdk", default-features = false, features = [ "wallet" ] } cdk-redb = { git = "https://github.com/cashubtc/cdk", default-features = false, features = [ "wallet" ] }
cdk-nostr = { git = "https://github.com/cashubtc/cdk", default-features = false }
# Others # Others
anyhow = "1.0.44" anyhow = "1.0.44"
-1
View File
@@ -23,4 +23,3 @@ tokio = { version = "1", default-features = false, features = [ "rt" ] }
cdk.workspace = true cdk.workspace = true
cdk-redb.workspace = true cdk-redb.workspace = true
cdk-nostr.workspace = true
+41
View File
@@ -175,6 +175,8 @@ pub(crate) async fn build_history_event<S>(
amount: u64, amount: u64,
created: EventId, created: EventId,
destroyed: &[EventId], destroyed: &[EventId],
redeemed: &[EventId],
sender: Option<PublicKey>,
) -> Result<Event, Error> ) -> Result<Event, Error>
where where
S: AsyncGetPublicKey + AsyncSignEvent + AsyncNip44 + ?Sized, S: AsyncGetPublicKey + AsyncSignEvent + AsyncNip44 + ?Sized,
@@ -200,11 +202,50 @@ where
"created".to_string(), "created".to_string(),
]); ]);
let mut tags: Vec<Tag> = redeemed
.iter()
.map(|id| Tag::custom("e", [id.to_hex(), String::new(), "redeemed".to_string()]))
.collect();
if let Some(sender) = sender {
tags.push(Tag::custom("p", [sender.to_hex()]));
}
let content = signer let content = signer
.nip44_encrypt_async(me, &serde_json::to_string(&rows)?) .nip44_encrypt_async(me, &serde_json::to_string(&rows)?)
.await?; .await?;
Ok(EventBuilder::new(Kind::CashuWalletSpendingHistory, content) Ok(EventBuilder::new(Kind::CashuWalletSpendingHistory, content)
.tags(tags)
.finalize_async(signer)
.await?)
}
pub(crate) async fn build_nutzap_info_event<S>(
signer: &S,
mints: &[Url],
relays: &[&str],
wallet_pubkey: PublicKey,
) -> Result<Event, Error>
where
S: AsyncGetPublicKey + AsyncSignEvent + ?Sized,
{
let mut tags: Vec<Tag> = relays
.iter()
.map(|relay| Tag::custom("relay", [relay.to_string()]))
.collect();
for mint in mints {
tags.push(Tag::custom("mint", [mint.to_string(), "sat".to_string()]));
}
tags.push(Tag::custom(
"pubkey",
[format!("02{}", wallet_pubkey.to_hex())],
));
Ok(EventBuilder::new(Kind::Custom(10019), "")
.tags(tags)
.finalize_async(signer) .finalize_async(signer)
.await?) .await?)
} }
+239 -10
View File
@@ -7,9 +7,9 @@ use std::collections::{HashMap, HashSet};
use std::str::FromStr; use std::str::FromStr;
use std::sync::Arc; use std::sync::Arc;
use anyhow::Error; use anyhow::{Error, anyhow};
use cdk::mint_url::MintUrl; use cdk::mint_url::MintUrl;
use cdk::nuts::CurrencyUnit; use cdk::nuts::{CurrencyUnit, Proof, Proofs, SpendingConditions};
use cdk::wallet::{Wallet, WalletBuilder}; use cdk::wallet::{Wallet, WalletBuilder};
use cdk_redb::WalletRedbDatabase; use cdk_redb::WalletRedbDatabase;
use common::config_dir; use common::config_dir;
@@ -52,6 +52,12 @@ pub struct WalletRegistry {
/// 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>,
/// NIP-61 nutzap events already claimed, recorded by our 7376 events
redeemed: HashSet<EventId>,
/// Nutzaps seen but not yet redeemed (wallet was not ready)
pending_nutzaps: Vec<Event>,
/// Nutzaps with a redemption job in flight
redeeming: HashSet<EventId>,
store: Option<Arc<WalletRedbDatabase>>, store: Option<Arc<WalletRedbDatabase>>,
wallets: HashMap<MintUrl, Wallet>, wallets: HashMap<MintUrl, Wallet>,
loading: bool, loading: bool,
@@ -67,6 +73,8 @@ struct LoadedState {
wallet: Option<keys::WalletData>, wallet: Option<keys::WalletData>,
tokens: HashMap<EventId, TokenEvent>, tokens: HashMap<EventId, TokenEvent>,
stale: HashSet<EventId>, stale: HashSet<EventId>,
redeemed: HashSet<EventId>,
nutzaps: Vec<Event>,
} }
impl WalletRegistry { impl WalletRegistry {
@@ -95,6 +103,9 @@ impl WalletRegistry {
tokens: HashMap::new(), tokens: HashMap::new(),
stale: HashSet::new(), stale: HashSet::new(),
quotes: Vec::new(), quotes: Vec::new(),
redeemed: HashSet::new(),
pending_nutzaps: Vec::new(),
redeeming: HashSet::new(),
store: None, store: None,
wallets: HashMap::new(), wallets: HashMap::new(),
loading: false, loading: false,
@@ -316,9 +327,18 @@ impl WalletRegistry {
let publish = cx.background_spawn(async move { let publish = cx.background_spawn(async move {
let me = signer.get_public_key_async().await?; let me = signer.get_public_key_async().await?;
let event = keys::build_wallet_event(&signer, &me, &privkey, &mints).await?; let event = keys::build_wallet_event(&signer, &me, &privkey, &mints).await?;
client.send_event(&event).await?; client.send_event(&event).await?;
client.database().save_event(&event).await?;
let info = keys::build_nutzap_info_event(
&signer,
&mints,
&state::BOOTSTRAP_RELAYS,
Keys::new(privkey.clone()).public_key(),
)
.await?;
client.send_event(&info).await?;
Ok::<_, Error>(keys::WalletData { Ok::<_, Error>(keys::WalletData {
event_id: event.id, event_id: event.id,
@@ -362,6 +382,9 @@ impl WalletRegistry {
self.tokens.clear(); self.tokens.clear();
self.stale.clear(); self.stale.clear();
self.quotes.clear(); self.quotes.clear();
self.redeemed.clear();
self.pending_nutzaps.clear();
self.redeeming.clear();
self.store = None; self.store = None;
self.wallets.clear(); self.wallets.clear();
self.loading = false; self.loading = false;
@@ -392,14 +415,22 @@ impl WalletRegistry {
while let Some(notification) = notifications.next().await { while let Some(notification) = notifications.next().await {
if let ClientNotification::Message { message, .. } = notification if let ClientNotification::Message { message, .. } = notification
&& let RelayMessage::Event { event, .. } = *message && let RelayMessage::Event { event, .. } = *message
&& me == Some(event.pubkey) {
let ours = me == Some(event.pubkey);
let wallet_event = ours
&& (matches!( && (matches!(
event.kind, event.kind,
Kind::CashuWallet Kind::CashuWallet
| Kind::CashuWalletUnspentProof | Kind::CashuWalletUnspentProof
| Kind::CashuWalletSpendingHistory | Kind::CashuWalletSpendingHistory
) || is_token_deletion(&event)) ) || is_token_deletion(&event));
{ let nutzap = event.kind == Kind::Custom(9321)
&& me.is_some_and(|me| event.tags.public_keys().any(|key| key == me));
if !wallet_event && !nutzap {
continue;
}
// Prune the dedup set before it grows unbounded // Prune the dedup set before it grows unbounded
if processed_events.len() >= MAX_PROCESSED { if processed_events.len() >= MAX_PROCESSED {
processed_events.clear(); processed_events.clear();
@@ -430,7 +461,9 @@ 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::EventDeletion => self.absorb_deletion(event, cx), Kind::EventDeletion => self.absorb_deletion(event, cx),
Kind::Custom(9321) => self.absorb_nutzap(event, cx),
_ => {} _ => {}
} }
} }
@@ -538,6 +571,74 @@ impl WalletRegistry {
} }
} }
/// 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) {
return;
}
if self.store.is_none() || self.wallets.is_empty() {
self.pending_nutzaps.push(event);
return;
}
let Some(wallet) = self.wallet.as_ref() else {
return;
};
let Some(mint) = nutzap_mint(&event) else {
log::warn!("nutzap {} has no mint", event.id);
return;
};
let Ok(mint_url) = MintUrl::from_str(&mint) else {
log::warn!("nutzap {} has an invalid mint: {mint}", event.id);
return;
};
if !self.wallets.contains_key(&mint_url) {
log::warn!("nutzap {} is on an unlisted mint: {mint_url}", event.id);
return;
}
let proofs = match nutzap_proofs(&event) {
Ok(proofs) => proofs,
Err(error) => {
log::warn!("nutzap {} has no usable proofs: {error:#}", event.id);
return;
}
};
let target = format!(
"02{}",
Keys::new(wallet.privkey.clone()).public_key().to_hex()
);
if !proofs.iter().all(|proof| locked_to(proof, &target)) {
log::warn!(
"nutzap {} proofs are not locked to the wallet key",
event.id
);
return;
}
self.redeeming.insert(event.id);
self.schedule_job(
ops::Job::RedeemNutzap {
mint: mint_url,
proofs,
nutzap_id: event.id,
sender: event.pubkey,
},
cx,
);
}
fn drain_pending_nutzaps(&mut self, cx: &mut Context<Self>) {
for event in std::mem::take(&mut self.pending_nutzaps) {
self.absorb_nutzap(event, cx);
}
}
/// Open the cdk store and build a wallet per mint that is not open yet. /// Open the cdk store and build a wallet per mint that is not open yet.
fn ensure_wallets(&mut self, cx: &mut Context<Self>) { fn ensure_wallets(&mut self, cx: &mut Context<Self>) {
let Some(data) = self.wallet.clone() else { let Some(data) = self.wallet.clone() else {
@@ -587,6 +688,7 @@ impl WalletRegistry {
} }
cx.notify(); cx.notify();
self.drain_pending_nutzaps(cx);
self.schedule_job(ops::Job::Reconcile, cx); self.schedule_job(ops::Job::Reconcile, cx);
return; return;
@@ -613,6 +715,7 @@ impl WalletRegistry {
} }
this.store_opening = false; this.store_opening = false;
this.drain_pending_nutzaps(cx);
cx.notify(); cx.notify();
this.schedule_job(ops::Job::Reconcile, cx); this.schedule_job(ops::Job::Reconcile, cx);
}) })
@@ -657,12 +760,30 @@ impl WalletRegistry {
let signer = NostrRegistry::global(cx).read(cx).signer().clone(); let signer = NostrRegistry::global(cx).read(cx).signer().clone();
let client = NostrRegistry::global(cx).read(cx).client().clone(); let client = NostrRegistry::global(cx).read(cx).client().clone();
let wallet_key = match self
.wallet
.as_ref()
.map(|wallet| cdk::nuts::SecretKey::from_str(&wallet.privkey.to_secret_hex()))
{
Some(Ok(key)) => key,
Some(Err(error)) => {
self.emit_error(format!("invalid wallet key: {error}"), cx);
return;
}
None => {
self.emit_error("Wallet is not ready".to_string(), cx);
return;
}
};
self.job_running = true; self.job_running = true;
self.tasks.push(cx.spawn(async move |this, cx| { self.tasks.push(cx.spawn(async move |this, cx| {
let result = Tokio::spawn_result(cx, async move { let result = Tokio::spawn_result(cx, async move {
let ctx = ops::JobCtx { let ctx = ops::JobCtx {
wallets, wallets,
wallet_key,
signer, signer,
client, client,
tokens, tokens,
@@ -731,6 +852,17 @@ impl WalletRegistry {
self.emit_updated(cx); self.emit_updated(cx);
log::info!("received {amount} sats"); log::info!("received {amount} sats");
} }
ops::Outcome::NutzapRedeemed {
update,
nutzap_id,
amount,
} => {
self.redeeming.remove(&nutzap_id);
self.redeemed.insert(nutzap_id);
self.apply_token_update(update);
self.emit_updated(cx);
log::info!("redeemed nutzap {nutzap_id} for {amount} sats");
}
} }
} }
@@ -746,9 +878,16 @@ 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.redeemed = state.redeemed;
cx.notify(); cx.notify();
self.ensure_wallets(cx); self.ensure_wallets(cx);
self.schedule_job(ops::Job::Reconcile, cx); self.schedule_job(ops::Job::Reconcile, cx);
// Claimed once the wallets are open; live redemptions continue
// through the notification listener
for event in state.nutzaps {
self.pending_nutzaps.push(event);
}
} }
fn load(&mut self, cx: &mut Context<Self>) { fn load(&mut self, cx: &mut Context<Self>) {
@@ -796,10 +935,10 @@ impl WalletRegistry {
// Subscribe only after the cache snapshot; from then on the state // Subscribe only after the cache snapshot; from then on the state
// advances exclusively through the notification listener. // advances exclusively through the notification listener.
let subscribed = let subscribed = cx.background_spawn(async move {
cx.background_spawn( subscribe_client.subscribe(wallet_filter(me)).await?;
async move { subscribe_client.subscribe(wallet_filter(me)).await }, subscribe_client.subscribe(nutzap_filter(me)).await
); });
if let Err(error) = subscribed.await { if let Err(error) = subscribed.await {
log::warn!("wallet subscription failed: {error:#}"); log::warn!("wallet subscription failed: {error:#}");
} }
@@ -822,9 +961,11 @@ where
S: AsyncNip44 + ?Sized, S: AsyncNip44 + ?Sized,
{ {
let events = client.database().query(wallet_filter(*me)).await?; let events = client.database().query(wallet_filter(*me)).await?;
let nutzap_events = client.database().query(nutzap_filter(*me)).await?;
let mut wallet_event: Option<&Event> = None; let mut wallet_event: Option<&Event> = None;
let mut token_events = Vec::new(); let mut token_events = Vec::new();
let mut history_events = Vec::new();
let mut deletions = Vec::new(); let mut deletions = Vec::new();
for event in &events { for event in &events {
@@ -840,11 +981,25 @@ where
} }
} }
Kind::CashuWalletUnspentProof => token_events.push(event), Kind::CashuWalletUnspentProof => token_events.push(event),
Kind::CashuWalletSpendingHistory => history_events.push(event),
Kind::EventDeletion if is_token_deletion(event) => deletions.push(event), Kind::EventDeletion if is_token_deletion(event) => deletions.push(event),
_ => {} _ => {}
} }
} }
// Nutzap events tagged redeemed in our history events are already claimed
let mut redeemed = HashSet::new();
for event in history_events {
for id in redeemed_ids(event) {
redeemed.insert(id);
}
}
let nutzaps = nutzap_events
.into_iter()
.filter(|event| !redeemed.contains(&event.id))
.collect();
let mut stale = HashSet::new(); let mut stale = HashSet::new();
for event in deletions { for event in deletions {
for tag in event.tags.iter() { for tag in event.tags.iter() {
@@ -901,6 +1056,8 @@ where
wallet, wallet,
tokens, tokens,
stale, stale,
redeemed,
nutzaps,
}) })
} }
@@ -940,6 +1097,13 @@ fn wallet_filter(author: PublicKey) -> Filter {
]) ])
} }
/// NIP-61 nutzaps: public kind 9321 events p-tagging our identity pubkey
fn nutzap_filter(me: PublicKey) -> Filter {
Filter::new()
.kinds([Kind::Custom(9321)])
.custom_tag(SingleLetterTag::LOWERCASE_P, me.to_hex())
}
fn is_token_deletion(event: &Event) -> bool { fn is_token_deletion(event: &Event) -> bool {
event.kind == Kind::EventDeletion event.kind == Kind::EventDeletion
&& event && event
@@ -947,3 +1111,68 @@ fn is_token_deletion(event: &Event) -> bool {
.iter() .iter()
.any(|tag| tag.kind() == "k" && tag.content() == Some("7375")) .any(|tag| tag.kind() == "k" && tag.content() == Some("7375"))
} }
/// The `u` tag carries the mint URL; fall back to the older `mint` tag
fn nutzap_mint(event: &Event) -> Option<String> {
let tag = event
.tags
.iter()
.find(|tag| tag.kind() == "u" && tag.content().is_some())
.or_else(|| {
event
.tags
.iter()
.find(|tag| tag.kind() == "mint" && tag.content().is_some())
})?;
tag.content().map(str::to_string)
}
fn nutzap_proofs(event: &Event) -> Result<Proofs, Error> {
let mut proofs = Vec::new();
for tag in event.tags.iter() {
if tag.kind() != "proof" {
continue;
}
let Some(value) = tag.content() else {
continue;
};
let proof: Proof =
serde_json::from_str(value).map_err(|error| anyhow!("invalid proof tag: {error}"))?;
proofs.push(proof);
}
if proofs.is_empty() {
return Err(anyhow!("no proofs"));
}
Ok(Proofs::from(proofs))
}
/// NIP-61 requires nutzap proofs to be P2PK-locked to the wallet pubkey.
fn locked_to(proof: &Proof, target: &str) -> bool {
let Ok(conditions) = SpendingConditions::try_from(&proof.secret) else {
return false;
};
conditions
.pubkeys()
.map(|keys| keys.iter().any(|key| key.to_string() == target))
.unwrap_or(false)
}
/// Nutzap event ids our history events marked as redeemed.
fn redeemed_ids(event: &Event) -> Vec<EventId> {
event
.tags
.iter()
.filter(|tag| {
let slice = tag.as_slice();
slice.len() >= 4 && slice[0] == "e" && slice[3] == "redeemed"
})
.filter_map(|tag| tag.content())
.filter_map(|hex| EventId::from_hex(hex).ok())
.collect()
}
+120 -14
View File
@@ -6,7 +6,10 @@ use cdk::Amount;
use cdk::amount::SplitTarget; 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::{CurrencyUnit, Id, MeltQuoteState, MintQuoteState, PaymentMethod, Proof, State}; use cdk::nuts::{
CurrencyUnit, Id, MeltQuoteState, MintQuoteState, PaymentMethod, Proof, Proofs, SecretKey,
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};
@@ -19,10 +22,38 @@ use crate::keys;
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub(crate) enum Job { pub(crate) enum Job {
Reconcile, Reconcile,
ClaimQuote { mint: MintUrl, quote_id: String }, ClaimQuote {
Melt { mint: MintUrl, invoice: String }, mint: MintUrl,
SendToken { mint: MintUrl, amount: u64 }, quote_id: String,
Receive { mint: MintUrl, token: String }, },
Melt {
mint: MintUrl,
invoice: String,
},
SendToken {
mint: MintUrl,
amount: u64,
},
Receive {
mint: MintUrl,
token: String,
},
RedeemNutzap {
mint: MintUrl,
proofs: Proofs,
nutzap_id: EventId,
sender: PublicKey,
},
}
/// History event payload for a token state change
pub(crate) struct History {
pub(crate) direction: TransactionDirection,
pub(crate) amount: u64,
/// NIP-61 nutzap events redeemed by this state change
pub(crate) redeemed: Vec<EventId>,
/// Author of the redeemed nutzap, for the unencrypted `p` tag
pub(crate) sender: Option<PublicKey>,
} }
pub(crate) struct TokenUpdate { pub(crate) struct TokenUpdate {
@@ -55,10 +86,17 @@ pub(crate) enum Outcome {
update: TokenUpdate, update: TokenUpdate,
amount: u64, amount: u64,
}, },
NutzapRedeemed {
update: TokenUpdate,
nutzap_id: EventId,
amount: u64,
},
} }
pub(crate) struct JobCtx<S> { pub(crate) struct JobCtx<S> {
pub(crate) wallets: HashMap<MintUrl, Wallet>, pub(crate) wallets: HashMap<MintUrl, Wallet>,
/// Wallet signing key, unlocking P2PK-locked nutzap proofs
pub(crate) wallet_key: SecretKey,
pub(crate) signer: S, pub(crate) signer: S,
pub(crate) client: Client, pub(crate) client: Client,
/// Relay token events at schedule time. /// Relay token events at schedule time.
@@ -88,7 +126,12 @@ where
wallet, wallet,
nostr_mint(&mint)?, nostr_mint(&mint)?,
&previous_ids(&ctx.tokens, &mint), &previous_ids(&ctx.tokens, &mint),
Some((TransactionDirection::In, amount)), Some(History {
direction: TransactionDirection::In,
amount: u64::from(quote.amount.unwrap_or_default()),
redeemed: Vec::new(),
sender: None,
}),
) )
.await?; .await?;
@@ -126,7 +169,12 @@ where
wallet, wallet,
nostr_mint(&mint)?, nostr_mint(&mint)?,
&previous_ids(&ctx.tokens, &mint), &previous_ids(&ctx.tokens, &mint),
Some((TransactionDirection::Out, amount)), Some(History {
direction: TransactionDirection::Out,
amount,
redeemed: Vec::new(),
sender: None,
}),
) )
.await?; .await?;
@@ -145,7 +193,12 @@ where
wallet, wallet,
nostr_mint(&mint)?, nostr_mint(&mint)?,
&previous_ids(&ctx.tokens, &mint), &previous_ids(&ctx.tokens, &mint),
Some((TransactionDirection::Out, amount)), Some(History {
direction: TransactionDirection::Out,
amount,
redeemed: Vec::new(),
sender: None,
}),
) )
.await?; .await?;
@@ -161,7 +214,12 @@ where
wallet, wallet,
nostr_mint(&mint)?, nostr_mint(&mint)?,
&previous_ids(&ctx.tokens, &mint), &previous_ids(&ctx.tokens, &mint),
Some((TransactionDirection::In, u64::from(received))), Some(History {
direction: TransactionDirection::In,
amount: u64::from(received),
redeemed: Vec::new(),
sender: None,
}),
) )
.await?; .await?;
@@ -170,6 +228,40 @@ where
amount: u64::from(received), amount: u64::from(received),
}) })
} }
Job::RedeemNutzap {
mint,
proofs,
nutzap_id,
sender,
} => {
let wallet = wallet_for(&ctx, &mint)?;
let options = ReceiveOptions {
p2pk_signing_keys: vec![ctx.wallet_key.clone()],
..Default::default()
};
let received = wallet.receive_proofs(proofs, options, None, None).await?;
let update = sync_token_events(
&ctx.signer,
&ctx.client,
wallet,
nostr_mint(&mint)?,
&previous_ids(&ctx.tokens, &mint),
Some(History {
direction: TransactionDirection::In,
amount: u64::from(received),
redeemed: vec![nutzap_id],
sender: Some(sender),
}),
)
.await?;
Ok(Outcome::NutzapRedeemed {
update,
nutzap_id,
amount: u64::from(received),
})
}
} }
} }
@@ -193,7 +285,12 @@ where
wallet, wallet,
nostr_mint(mint_url)?, nostr_mint(mint_url)?,
&previous_ids(&tokens, mint_url), &previous_ids(&tokens, mint_url),
Some((TransactionDirection::In, u64::from(minted))), Some(History {
direction: TransactionDirection::In,
amount: u64::from(minted),
redeemed: Vec::new(),
sender: None,
}),
) )
.await?; .await?;
@@ -246,7 +343,7 @@ async fn sync_token_events<S>(
wallet: &Wallet, wallet: &Wallet,
mint: Url, mint: Url,
previous: &[EventId], previous: &[EventId],
history: Option<(TransactionDirection, u64)>, history: Option<History>,
) -> Result<TokenUpdate, Error> ) -> Result<TokenUpdate, Error>
where where
S: AsyncGetPublicKey + AsyncSignEvent + AsyncNip44 + Sync + ?Sized, S: AsyncGetPublicKey + AsyncSignEvent + AsyncNip44 + Sync + ?Sized,
@@ -268,9 +365,18 @@ where
client.send_event(&deletion).await?; client.send_event(&deletion).await?;
} }
if let Some((direction, amount)) = history { if let Some(history) = history {
let history_event = let history_event = keys::build_history_event(
keys::build_history_event(signer, &me, direction, amount, event.id, previous).await?; signer,
&me,
history.direction,
history.amount,
event.id,
previous,
&history.redeemed,
history.sender,
)
.await?;
client.send_event(&history_event).await?; client.send_event(&history_event).await?;
} }
+33 -8
View File
@@ -1,7 +1,8 @@
# NIP-60: cashu wallet — implementation plan # NIP-60: cashu wallet — implementation plan
Spec: https://github.com/nostr-protocol/nips/blob/master/60.md Spec: https://github.com/nostr-protocol/nips/blob/master/60.md
CDK: https://github.com/cashubtc/cdk (crates `cdk`, `cdk-redb`, `cdk-nostr`, from git main) CDK: https://github.com/cashubtc/cdk (crates `cdk`, `cdk-redb`, from git main)
NIP-61: https://github.com/nostr-protocol/nips/blob/master/61.md
NIP-60 stores a user's cashu wallet state on relays: the wallet config NIP-60 stores a user's cashu wallet state on relays: the wallet config
(kind `17375`), its unspent proofs (kind `7375`), and an optional spending (kind `17375`), its unspent proofs (kind `7375`), and an optional spending
@@ -28,9 +29,13 @@ Key mechanics:
key). It exists to unlock P2PK-locked ecash, i.e. redeeming NIP-61 key). It exists to unlock P2PK-locked ecash, i.e. redeeming NIP-61
nutzaps. nutzaps.
- NIP-60 itself does not move money to/from others. Receiving ecash from - NIP-60 itself does not move money to/from others. Receiving ecash from
peers is NIP-61 (nutzaps): gift-wrapped token events P2PK-locked to the peers is NIP-61 (nutzaps): public `kind:9321` events published by the
wallet pubkey. Redemption is `receive` + a `7376` with an `e` tag sender, p-tagging the recipient's nostr identity pubkey, carrying
marked `redeemed` (this one tag stays unencrypted). P2PK-locked proofs (`proof` tags) on a trusted mint (`u` tag). Redemption
is `receive_proofs` with the wallet signing key + a `7376` whose
unencrypted tags mark the nutzap `redeemed` and carry the sender pubkey.
- NIP-61 discovery runs through a `kind:10019` informational event
(relays, mints, wallet pubkey) published alongside the wallet event.
- History (7376) and quote events (7374) are optional; the spec recommends - History (7376) and quote events (7374) are optional; the spec recommends
local state for quotes. We publish 7376, skip 7374 (cdk keeps quotes in local state for quotes. We publish 7376, skip 7374 (cdk keeps quotes in
its local DB). its local DB).
@@ -246,11 +251,31 @@ Implemented in `crates/nip60/src/ops.rs` plus registry wiring:
Deferred: NUT-20/quote events (`7374`, per spec local state is fine), Deferred: NUT-20/quote events (`7374`, per spec local state is fine),
sending tokens in-chat (v1 copies the token string). sending tokens in-chat (v1 copies the token string).
### Phase 3 — NIP-61 redemption (cdk-nostr) ### Phase 3 — NIP-61 redemption (done)
1. `cdk_nostr::NostrInbox` on the wallet key + relays, started in Spec note: NIP-61 changed since this plan was first written — nutzaps are
`Tokio::spawn`, stopped on reset; rumors parsed into proofs + mint. now public `kind:9321` events p-tagging the recipient's nostr identity
2. Redemption path with `p2pk_signing_keys`, `redeemed` history tag. pubkey (the old draft's `kind:938` + gift-wrap shape is gone). So
`cdk_nostr::NostrInbox` (a NIP-17 gift-wrap listener) is not the right
tool; the dependency was dropped and reception rides the app's existing
nostr client.
Implemented:
- `setup` also publishes a `kind:10019` nutzap info event (bootstrap
relays, wallet mints, wallet pubkey) so other clients can nutzap us.
- A second subscription (`kinds: [9321], #p: me`) feeds nutzaps through
the same notification listener; the cache load scans stored `9321`s
too, so unredeemed nutzaps survive restarts.
- `absorb_nutzap` parses `proof`/`u` tags, resolves the mint against the
open wallets, verifies every proof is P2PK-locked to the wallet pubkey
(NUT-10 spending conditions), and enqueues `Job::RedeemNutzap` through
the serialized job queue.
- The job redeems via `wallet.receive_proofs` with the wallet key in
`p2pk_signing_keys`, then syncs with a `7376` history event whose
unencrypted tags mark the nutzap `redeemed` and tag the sender.
- Already-redeemed nutzaps (from cached `7376` tags) and ones with a job
in flight are skipped; failed redemptions are retried on relay echo.
### Phase 4 — UI ### Phase 4 — UI