From 8c5b816ebfda91dd4941c0cd287bdad4d3ed2c36 Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Fri, 6 Nov 2026 09:08:44 +0700 Subject: [PATCH] implement nip61 nutzap redemption --- Cargo.lock | 14 --- Cargo.toml | 1 - crates/nip60/Cargo.toml | 1 - crates/nip60/src/keys.rs | 41 +++++++ crates/nip60/src/lib.rs | 257 ++++++++++++++++++++++++++++++++++++--- crates/nip60/src/ops.rs | 134 +++++++++++++++++--- docs/nip60.md | 41 +++++-- 7 files changed, 437 insertions(+), 52 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 36f1d33c..0b5a8d27 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1329,19 +1329,6 @@ dependencies = [ "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]] name = "cdk-redb" version = "0.18.0" @@ -6133,7 +6120,6 @@ version = "2.0.0-alpha3" dependencies = [ "anyhow", "cdk", - "cdk-nostr", "cdk-redb", "common", "flume 0.11.1", diff --git a/Cargo.toml b/Cargo.toml index b1bf19bd..6a300a48 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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. 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-nostr = { git = "https://github.com/cashubtc/cdk", default-features = false } # Others anyhow = "1.0.44" diff --git a/crates/nip60/Cargo.toml b/crates/nip60/Cargo.toml index b00feac0..fe2ed07c 100644 --- a/crates/nip60/Cargo.toml +++ b/crates/nip60/Cargo.toml @@ -23,4 +23,3 @@ tokio = { version = "1", default-features = false, features = [ "rt" ] } cdk.workspace = true cdk-redb.workspace = true -cdk-nostr.workspace = true diff --git a/crates/nip60/src/keys.rs b/crates/nip60/src/keys.rs index 08cdf66a..c07a4072 100644 --- a/crates/nip60/src/keys.rs +++ b/crates/nip60/src/keys.rs @@ -175,6 +175,8 @@ pub(crate) async fn build_history_event( amount: u64, created: EventId, destroyed: &[EventId], + redeemed: &[EventId], + sender: Option, ) -> Result where S: AsyncGetPublicKey + AsyncSignEvent + AsyncNip44 + ?Sized, @@ -200,11 +202,50 @@ where "created".to_string(), ]); + let mut tags: Vec = 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 .nip44_encrypt_async(me, &serde_json::to_string(&rows)?) .await?; Ok(EventBuilder::new(Kind::CashuWalletSpendingHistory, content) + .tags(tags) + .finalize_async(signer) + .await?) +} + +pub(crate) async fn build_nutzap_info_event( + signer: &S, + mints: &[Url], + relays: &[&str], + wallet_pubkey: PublicKey, +) -> Result +where + S: AsyncGetPublicKey + AsyncSignEvent + ?Sized, +{ + let mut tags: Vec = 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) .await?) } diff --git a/crates/nip60/src/lib.rs b/crates/nip60/src/lib.rs index 7f752e2b..c0e3cb35 100644 --- a/crates/nip60/src/lib.rs +++ b/crates/nip60/src/lib.rs @@ -7,9 +7,9 @@ use std::collections::{HashMap, HashSet}; use std::str::FromStr; use std::sync::Arc; -use anyhow::Error; +use anyhow::{Error, anyhow}; use cdk::mint_url::MintUrl; -use cdk::nuts::CurrencyUnit; +use cdk::nuts::{CurrencyUnit, Proof, Proofs, SpendingConditions}; use cdk::wallet::{Wallet, WalletBuilder}; use cdk_redb::WalletRedbDatabase; 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 stale: HashSet, quotes: Vec, + /// NIP-61 nutzap events already claimed, recorded by our 7376 events + redeemed: HashSet, + /// Nutzaps seen but not yet redeemed (wallet was not ready) + pending_nutzaps: Vec, + /// Nutzaps with a redemption job in flight + redeeming: HashSet, store: Option>, wallets: HashMap, loading: bool, @@ -67,6 +73,8 @@ struct LoadedState { wallet: Option, tokens: HashMap, stale: HashSet, + redeemed: HashSet, + nutzaps: Vec, } impl WalletRegistry { @@ -95,6 +103,9 @@ impl WalletRegistry { tokens: HashMap::new(), stale: HashSet::new(), quotes: Vec::new(), + redeemed: HashSet::new(), + pending_nutzaps: Vec::new(), + redeeming: HashSet::new(), store: None, wallets: HashMap::new(), loading: false, @@ -316,9 +327,18 @@ impl WalletRegistry { let publish = cx.background_spawn(async move { let me = signer.get_public_key_async().await?; + let event = keys::build_wallet_event(&signer, &me, &privkey, &mints).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 { event_id: event.id, @@ -362,6 +382,9 @@ impl WalletRegistry { self.tokens.clear(); self.stale.clear(); self.quotes.clear(); + self.redeemed.clear(); + self.pending_nutzaps.clear(); + self.redeeming.clear(); self.store = None; self.wallets.clear(); self.loading = false; @@ -392,14 +415,22 @@ impl WalletRegistry { while let Some(notification) = notifications.next().await { if let ClientNotification::Message { message, .. } = notification && let RelayMessage::Event { event, .. } = *message - && me == Some(event.pubkey) - && (matches!( - event.kind, - Kind::CashuWallet - | Kind::CashuWalletUnspentProof - | Kind::CashuWalletSpendingHistory - ) || is_token_deletion(&event)) { + let ours = me == Some(event.pubkey); + let wallet_event = ours + && (matches!( + event.kind, + Kind::CashuWallet + | Kind::CashuWalletUnspentProof + | Kind::CashuWalletSpendingHistory + ) || 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 if processed_events.len() >= MAX_PROCESSED { processed_events.clear(); @@ -430,7 +461,9 @@ impl WalletRegistry { match event.kind { Kind::CashuWallet => self.absorb_wallet(event, cx), Kind::CashuWalletUnspentProof => self.absorb_token(event, cx), + Kind::CashuWalletSpendingHistory => {} 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) { + 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) { + 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. fn ensure_wallets(&mut self, cx: &mut Context) { let Some(data) = self.wallet.clone() else { @@ -587,6 +688,7 @@ impl WalletRegistry { } cx.notify(); + self.drain_pending_nutzaps(cx); self.schedule_job(ops::Job::Reconcile, cx); return; @@ -613,6 +715,7 @@ impl WalletRegistry { } this.store_opening = false; + this.drain_pending_nutzaps(cx); cx.notify(); this.schedule_job(ops::Job::Reconcile, cx); }) @@ -657,12 +760,30 @@ impl WalletRegistry { let signer = NostrRegistry::global(cx).read(cx).signer().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.tasks.push(cx.spawn(async move |this, cx| { let result = Tokio::spawn_result(cx, async move { let ctx = ops::JobCtx { wallets, + wallet_key, signer, client, tokens, @@ -731,6 +852,17 @@ impl WalletRegistry { self.emit_updated(cx); 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.tokens = state.tokens; self.stale = state.stale; + self.redeemed = state.redeemed; cx.notify(); self.ensure_wallets(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) { @@ -796,10 +935,10 @@ impl WalletRegistry { // Subscribe only after the cache snapshot; from then on the state // advances exclusively through the notification listener. - let subscribed = - cx.background_spawn( - async move { subscribe_client.subscribe(wallet_filter(me)).await }, - ); + let subscribed = cx.background_spawn(async move { + subscribe_client.subscribe(wallet_filter(me)).await?; + subscribe_client.subscribe(nutzap_filter(me)).await + }); if let Err(error) = subscribed.await { log::warn!("wallet subscription failed: {error:#}"); } @@ -822,9 +961,11 @@ where S: AsyncNip44 + ?Sized, { 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 token_events = Vec::new(); + let mut history_events = Vec::new(); let mut deletions = Vec::new(); for event in &events { @@ -840,11 +981,25 @@ where } } Kind::CashuWalletUnspentProof => token_events.push(event), + Kind::CashuWalletSpendingHistory => history_events.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(); for event in deletions { for tag in event.tags.iter() { @@ -901,6 +1056,8 @@ where wallet, tokens, 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 { event.kind == Kind::EventDeletion && event @@ -947,3 +1111,68 @@ fn is_token_deletion(event: &Event) -> bool { .iter() .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 { + 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 { + 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 { + 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() +} diff --git a/crates/nip60/src/ops.rs b/crates/nip60/src/ops.rs index 5793dde2..eb8a5ab6 100644 --- a/crates/nip60/src/ops.rs +++ b/crates/nip60/src/ops.rs @@ -6,7 +6,10 @@ use cdk::Amount; use cdk::amount::SplitTarget; use cdk::mint_url::MintUrl; 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::wallet::types::ProofInfo; use cdk::wallet::{ReceiveOptions, SendOptions, Wallet}; @@ -19,10 +22,38 @@ use crate::keys; #[derive(Debug, Clone)] pub(crate) enum Job { Reconcile, - ClaimQuote { mint: MintUrl, quote_id: String }, - Melt { mint: MintUrl, invoice: String }, - SendToken { mint: MintUrl, amount: u64 }, - Receive { mint: MintUrl, token: String }, + ClaimQuote { + mint: MintUrl, + quote_id: 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, + /// Author of the redeemed nutzap, for the unencrypted `p` tag + pub(crate) sender: Option, } pub(crate) struct TokenUpdate { @@ -55,10 +86,17 @@ pub(crate) enum Outcome { update: TokenUpdate, amount: u64, }, + NutzapRedeemed { + update: TokenUpdate, + nutzap_id: EventId, + amount: u64, + }, } pub(crate) struct JobCtx { pub(crate) wallets: HashMap, + /// Wallet signing key, unlocking P2PK-locked nutzap proofs + pub(crate) wallet_key: SecretKey, pub(crate) signer: S, pub(crate) client: Client, /// Relay token events at schedule time. @@ -88,7 +126,12 @@ where wallet, nostr_mint(&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?; @@ -126,7 +169,12 @@ where wallet, nostr_mint(&mint)?, &previous_ids(&ctx.tokens, &mint), - Some((TransactionDirection::Out, amount)), + Some(History { + direction: TransactionDirection::Out, + amount, + redeemed: Vec::new(), + sender: None, + }), ) .await?; @@ -145,7 +193,12 @@ where wallet, nostr_mint(&mint)?, &previous_ids(&ctx.tokens, &mint), - Some((TransactionDirection::Out, amount)), + Some(History { + direction: TransactionDirection::Out, + amount, + redeemed: Vec::new(), + sender: None, + }), ) .await?; @@ -161,7 +214,12 @@ where wallet, nostr_mint(&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?; @@ -170,6 +228,40 @@ where 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, nostr_mint(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?; @@ -246,7 +343,7 @@ async fn sync_token_events( wallet: &Wallet, mint: Url, previous: &[EventId], - history: Option<(TransactionDirection, u64)>, + history: Option, ) -> Result where S: AsyncGetPublicKey + AsyncSignEvent + AsyncNip44 + Sync + ?Sized, @@ -268,9 +365,18 @@ where client.send_event(&deletion).await?; } - if let Some((direction, amount)) = history { - let history_event = - keys::build_history_event(signer, &me, direction, amount, event.id, previous).await?; + if let Some(history) = history { + let history_event = keys::build_history_event( + signer, + &me, + history.direction, + history.amount, + event.id, + previous, + &history.redeemed, + history.sender, + ) + .await?; client.send_event(&history_event).await?; } diff --git a/docs/nip60.md b/docs/nip60.md index e73bd1cf..91caac12 100644 --- a/docs/nip60.md +++ b/docs/nip60.md @@ -1,7 +1,8 @@ # NIP-60: cashu wallet — implementation plan 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 (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 nutzaps. - 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 - wallet pubkey. Redemption is `receive` + a `7376` with an `e` tag - marked `redeemed` (this one tag stays unencrypted). + peers is NIP-61 (nutzaps): public `kind:9321` events published by the + sender, p-tagging the recipient's nostr identity pubkey, carrying + 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 local state for quotes. We publish 7376, skip 7374 (cdk keeps quotes in 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), 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 - `Tokio::spawn`, stopped on reset; rumors parsed into proofs + mint. -2. Redemption path with `p2pk_signing_keys`, `redeemed` history tag. +Spec note: NIP-61 changed since this plan was first written — nutzaps are +now public `kind:9321` events p-tagging the recipient's nostr identity +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