diff --git a/crates/nip60/src/keys.rs b/crates/nip60/src/keys.rs index c07a4072..27adf39d 100644 --- a/crates/nip60/src/keys.rs +++ b/crates/nip60/src/keys.rs @@ -7,6 +7,8 @@ use sha2::Sha256; const SEED_SALT: &[u8] = b"coop nip60 seed salt"; const SEED_INFO: &[u8] = b"coop cashu seed"; +/// Parsed 17375 wallet event: the wallet key and its mint list. +#[derive(Clone)] pub(crate) struct WalletData { pub(crate) event_id: EventId, pub(crate) created_at: Timestamp, @@ -14,32 +16,68 @@ pub(crate) struct WalletData { pub(crate) mints: Vec, } -impl Clone for WalletData { - fn clone(&self) -> Self { +impl WalletData { + /// A new wallet with a fresh key. + pub(crate) fn generate(first_mint: Url) -> Self { Self { - event_id: self.event_id, - created_at: self.created_at, - privkey: self.privkey.clone(), - mints: self.mints.clone(), + event_id: EventId::from_byte_array([0; 32]), + created_at: Timestamp::from_secs(0), + privkey: SecretKey::generate(), + mints: vec![first_mint], } } + + /// The cdk seed, derived deterministically from the wallet key. + pub(crate) fn derive_seed(privkey: &SecretKey) -> Result<[u8; 64], Error> { + let ikm = privkey.to_secret_bytes(); + let hk = Hkdf::::new(Some(SEED_SALT), &ikm); + let mut seed = [0u8; 64]; + hk.expand(SEED_INFO, &mut seed) + .map_err(|error| anyhow!("seed derivation failed: {error}"))?; + Ok(seed) + } + + /// Decrypt and parse a 17375 event published by this account. + pub(crate) async fn decrypt(signer: &S, me: &PublicKey, event: &Event) -> Result + where + S: AsyncNip44 + ?Sized, + { + if event.pubkey != *me { + return Err(anyhow!("wallet event was not published by this account")); + } + + let content = signer.nip44_decrypt_async(me, &event.content).await?; + let (privkey, mints) = parse_wallet_content(&content)?; + + Ok(Self { + event_id: event.id, + created_at: event.created_at, + privkey, + mints, + }) + } + + /// Build the encrypted 17375 event for this wallet data. + pub(crate) async fn to_event(&self, signer: &S, me: &PublicKey) -> Result + where + S: AsyncGetPublicKey + AsyncSignEvent + AsyncNip44 + ?Sized, + { + let mut rows = vec![vec!["privkey".to_string(), self.privkey.to_secret_hex()]]; + for mint in &self.mints { + rows.push(vec!["mint".to_string(), mint.to_string()]); + } + + let content = signer + .nip44_encrypt_async(me, &serde_json::to_string(&rows)?) + .await?; + + Ok(EventBuilder::new(Kind::CashuWallet, content) + .finalize_async(signer) + .await?) + } } -pub(crate) fn generate_privkey() -> SecretKey { - SecretKey::generate() -} - -/// Derive the cdk seed from the wallet key. -pub(crate) fn derive_seed(privkey: &SecretKey) -> Result<[u8; 64], Error> { - let ikm = privkey.to_secret_bytes(); - let hk = Hkdf::::new(Some(SEED_SALT), &ikm); - let mut seed = [0u8; 64]; - hk.expand(SEED_INFO, &mut seed) - .map_err(|error| anyhow!("seed derivation failed: {error}"))?; - Ok(seed) -} - -pub(crate) fn parse_wallet_content(content: &str) -> Result<(SecretKey, Vec), Error> { +fn parse_wallet_content(content: &str) -> Result<(SecretKey, Vec), Error> { let rows: Vec> = serde_json::from_str(content).map_err(|error| anyhow!("invalid wallet event: {error}"))?; @@ -70,29 +108,6 @@ pub(crate) fn parse_wallet_content(content: &str) -> Result<(SecretKey, Vec Ok((privkey, mints)) } -pub(crate) async fn decrypt_wallet_event( - signer: &S, - me: &PublicKey, - event: &Event, -) -> Result -where - S: AsyncNip44 + ?Sized, -{ - if event.pubkey != *me { - return Err(anyhow!("wallet event was not published by this account")); - } - - let content = signer.nip44_decrypt_async(me, &event.content).await?; - let (privkey, mints) = parse_wallet_content(&content)?; - - Ok(WalletData { - event_id: event.id, - created_at: event.created_at, - privkey, - mints, - }) -} - pub(crate) async fn decrypt_token_event( signer: &S, me: &PublicKey, @@ -113,29 +128,6 @@ where Ok(token) } -pub(crate) async fn build_wallet_event( - signer: &S, - me: &PublicKey, - privkey: &SecretKey, - mints: &[Url], -) -> Result -where - S: AsyncGetPublicKey + AsyncSignEvent + AsyncNip44 + ?Sized, -{ - let mut rows = vec![vec!["privkey".to_string(), privkey.to_secret_hex()]]; - for mint in mints { - rows.push(vec!["mint".to_string(), mint.to_string()]); - } - - let content = signer - .nip44_encrypt_async(me, &serde_json::to_string(&rows)?) - .await?; - - Ok(EventBuilder::new(Kind::CashuWallet, content) - .finalize_async(signer) - .await?) -} - pub(crate) async fn build_token_event( signer: &S, me: &PublicKey, @@ -167,23 +159,29 @@ where .await?) } -#[allow(clippy::too_many_arguments)] +/// Encrypted 7376 history payload for one 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) async fn build_history_event( signer: &S, me: &PublicKey, - direction: TransactionDirection, - amount: u64, + history: &History, created: EventId, destroyed: &[EventId], - redeemed: &[EventId], - sender: Option, ) -> Result where S: AsyncGetPublicKey + AsyncSignEvent + AsyncNip44 + ?Sized, { let mut rows = vec![ - vec!["direction".to_string(), direction.to_string()], - vec!["amount".to_string(), amount.to_string()], + vec!["direction".to_string(), history.direction.to_string()], + vec!["amount".to_string(), history.amount.to_string()], ]; for id in destroyed { @@ -202,12 +200,13 @@ where "created".to_string(), ]); - let mut tags: Vec = redeemed + let mut tags: Vec = history + .redeemed .iter() .map(|id| Tag::custom("e", [id.to_hex(), String::new(), "redeemed".to_string()])) .collect(); - if let Some(sender) = sender { + if let Some(sender) = history.sender { tags.push(Tag::custom("p", [sender.to_hex()])); } @@ -224,7 +223,7 @@ where pub(crate) async fn build_nutzap_info_event( signer: &S, mints: &[Url], - relays: &[&str], + relays: &[RelayUrl], wallet_pubkey: PublicKey, ) -> Result where @@ -282,11 +281,12 @@ mod tests { #[test] fn seed_is_deterministic_per_key() { let key = SecretKey::generate(); - let first = derive_seed(&key).expect("seed derivation cannot fail"); - let second = derive_seed(&key).expect("seed derivation cannot fail"); + let first = WalletData::derive_seed(&key).expect("seed derivation cannot fail"); + let second = WalletData::derive_seed(&key).expect("seed derivation cannot fail"); assert_eq!(first, second); - let other = derive_seed(&SecretKey::generate()).expect("seed derivation cannot fail"); + let other = + WalletData::derive_seed(&SecretKey::generate()).expect("seed derivation cannot fail"); assert_ne!(first, other); } } diff --git a/crates/nip60/src/lib.rs b/crates/nip60/src/lib.rs index c0e3cb35..bc306152 100644 --- a/crates/nip60/src/lib.rs +++ b/crates/nip60/src/lib.rs @@ -43,7 +43,7 @@ pub struct MintQuoteEntry { pub quote_id: String, pub request: String, pub amount: u64, - pub mint: Url, + pub mint: MintUrl, } pub struct WalletRegistry { @@ -137,61 +137,17 @@ impl WalletRegistry { /// Create a lightning invoice that funds the wallet once paid. pub fn create_mint_quote(&mut self, amount: u64, cx: &mut Context) { - let Some(mint) = self - .wallet - .as_ref() - .and_then(|wallet| wallet.mints.first().cloned()) - else { - self.emit_error("No wallet is set up".to_string(), cx); + let Some(mint_url) = self.primary_mint(cx) else { return; }; - let Ok(mint_url) = MintUrl::from_str(mint.as_str()) else { - self.emit_error("Invalid mint URL".to_string(), cx); - return; - }; - - let Some(wallet) = self.wallets.get(&mint_url).cloned() else { - self.emit_error("Wallet is not ready".to_string(), cx); - return; - }; - - let created = cx.background_spawn(async move { - let quote = wallet - .mint_quote( - cdk::nuts::PaymentMethod::Known(cdk::nuts::nut00::KnownMethod::Bolt11), - Some(cdk::Amount::from(amount)), - None, - None, - ) - .await?; - - Ok::<_, Error>((quote.id, quote.request)) - }); - - self.tasks.push(cx.spawn(async move |this, cx| { - let (quote_id, request) = match created.await { - Ok(quote) => quote, - Err(error) => { - this.update(cx, |this, cx| { - this.emit_error(format!("{error:#}"), cx); - }) - .ok(); - return; - } - }; - - this.update(cx, |this, cx| { - this.quotes.push(MintQuoteEntry { - quote_id, - request, - amount, - mint: mint.clone(), - }); - this.emit_updated(cx); - }) - .ok(); - })); + self.schedule_job( + ops::Job::CreateQuote { + mint: mint_url, + amount, + }, + cx, + ); } /// Check a mint quote with the mint and claim it once it is paid. @@ -201,14 +157,9 @@ impl WalletRegistry { return; }; - let Ok(mint_url) = MintUrl::from_str(entry.mint.as_str()) else { - self.emit_error("Invalid mint URL".to_string(), cx); - return; - }; - self.schedule_job( ops::Job::ClaimQuote { - mint: mint_url, + mint: entry.mint.clone(), quote_id, }, cx, @@ -217,17 +168,7 @@ impl WalletRegistry { /// Pay a lightning invoice from the wallet. pub fn melt(&mut self, invoice: String, cx: &mut Context) { - let Some(mint) = self - .wallet - .as_ref() - .and_then(|wallet| wallet.mints.first().cloned()) - else { - self.emit_error("No wallet is set up".to_string(), cx); - return; - }; - - let Ok(mint_url) = MintUrl::from_str(mint.as_str()) else { - self.emit_error("Invalid mint URL".to_string(), cx); + let Some(mint_url) = self.primary_mint(cx) else { return; }; @@ -242,17 +183,7 @@ impl WalletRegistry { /// Send ecash as a token string the recipient can paste anywhere. pub fn send_token(&mut self, amount: u64, cx: &mut Context) { - let Some(mint) = self - .wallet - .as_ref() - .and_then(|wallet| wallet.mints.first().cloned()) - else { - self.emit_error("No wallet is set up".to_string(), cx); - return; - }; - - let Ok(mint_url) = MintUrl::from_str(mint.as_str()) else { - self.emit_error("Invalid mint URL".to_string(), cx); + let Some(mint_url) = self.primary_mint(cx) else { return; }; @@ -265,6 +196,22 @@ impl WalletRegistry { ); } + /// The first wallet mint; the UI is effectively single-mint in v1. + fn primary_mint(&self, cx: &mut Context) -> Option { + let Some(mint) = self.wallet.as_ref().and_then(|wallet| wallet.mints.first()) else { + self.emit_error("No wallet is set up".to_string(), cx); + return None; + }; + + match MintUrl::from_str(mint.as_str()) { + Ok(mint_url) => Some(mint_url), + Err(error) => { + self.emit_error(format!("invalid mint url: {error}"), cx); + None + } + } + } + /// Receive a cashu token created elsewhere. pub fn receive_token(&mut self, token: String, cx: &mut Context) { let mint = match cdk::nuts::Token::from_str(&token) { @@ -306,19 +253,15 @@ impl WalletRegistry { } }; - let (privkey, mints) = match self - .wallet - .as_ref() - .map(|wallet| (wallet.privkey.clone(), wallet.mints.clone())) - { - Some((privkey, mut mints)) => { - if mints.contains(&mint) { + let mut data = match self.wallet.clone() { + Some(mut data) => { + if data.mints.contains(&mint) { return; } - mints.push(mint.clone()); - (privkey, mints) + data.mints.push(mint.clone()); + data } - None => (keys::generate_privkey(), vec![mint.clone()]), + None => keys::WalletData::generate(mint.clone()), }; let nostr = NostrRegistry::global(cx); @@ -328,24 +271,30 @@ 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?; + let event = data.to_event(&signer, &me).await?; client.send_event(&event).await?; + let filter = Filter::new().kind(Kind::RelayList).author(me).limit(1); + let relays = match client.database().query(filter).await?.into_iter().next() { + Some(list) => nip65::extract_relay_list(&list) + .map(|(relay, _)| relay) + .collect::>(), + None => Vec::new(), + }; + let info = keys::build_nutzap_info_event( &signer, - &mints, - &state::BOOTSTRAP_RELAYS, - Keys::new(privkey.clone()).public_key(), + &data.mints, + &relays, + Keys::new(data.privkey.clone()).public_key(), ) .await?; client.send_event(&info).await?; - Ok::<_, Error>(keys::WalletData { - event_id: event.id, - created_at: event.created_at, - privkey, - mints, - }) + data.event_id = event.id; + data.created_at = event.created_at; + + Ok::<_, Error>(data) }); self.tasks.push(cx.spawn(async move |this, cx| { @@ -406,10 +355,10 @@ impl WalletRegistry { let (tx, rx) = flume::bounded::(100); self.notification_listener = Some(cx.background_spawn(async move { + const MAX_PROCESSED: usize = 1000; + let me = signer.get_public_key_async().await.ok(); let mut notifications = client.notifications(); - - const MAX_PROCESSED: usize = 1000; let mut processed_events = HashSet::new(); while let Some(notification) = notifications.next().await { @@ -417,13 +366,15 @@ impl WalletRegistry { && let RelayMessage::Event { event, .. } = *message { let ours = me == Some(event.pubkey); - let wallet_event = ours - && (matches!( - event.kind, - Kind::CashuWallet - | Kind::CashuWalletUnspentProof - | Kind::CashuWalletSpendingHistory - ) || is_token_deletion(&event)); + let is_cashu = matches!( + event.kind, + Kind::CashuWallet + | Kind::CashuWalletUnspentProof + | Kind::CashuWalletSpendingHistory + ); + + let wallet_event = ours && is_cashu || is_token_deletion(&event); + let nutzap = event.kind == Kind::Custom(9321) && me.is_some_and(|me| event.tags.public_keys().any(|key| key == me)); @@ -483,7 +434,7 @@ impl WalletRegistry { let decrypted = cx.background_spawn(async move { let me = signer.get_public_key_async().await?; - keys::decrypt_wallet_event(&signer, &me, &event).await + keys::WalletData::decrypt(&signer, &me, &event).await }); self.tasks.push(cx.spawn(async move |this, cx| { @@ -499,9 +450,17 @@ impl WalletRegistry { }; this.update(cx, |this, cx| { - this.wallet = Some(data); - this.ensure_wallets(cx); - this.emit_updated(cx); + // A stale decrypt may land after a newer wallet event + let take_over = match &this.wallet { + Some(current) => data.created_at >= current.created_at, + None => true, + }; + + if take_over { + this.wallet = Some(data); + this.ensure_wallets(cx); + this.emit_updated(cx); + } }) .ok(); })); @@ -525,10 +484,7 @@ impl WalletRegistry { let token = match decrypted.await { Ok(token) => token, Err(error) => { - this.update(cx, |this, cx| { - this.emit_error(format!("{error:#}"), cx); - }) - .ok(); + log::warn!("skipping unreadable token event {event_id}: {error:#}"); return; } }; @@ -577,46 +533,33 @@ impl WalletRegistry { return; } + let Some(wallet) = self.wallet.as_ref() else { + log::warn!("dropping nutzap {}: no wallet is set up", 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)) { + let nutzap = match Nutzap::from_event(&event, &target) { + Ok(nutzap) => nutzap, + Err(error) => { + log::warn!("skipping nutzap {}: {error:#}", event.id); + return; + } + }; + + if !self.wallets.contains_key(&nutzap.mint) { log::warn!( - "nutzap {} proofs are not locked to the wallet key", - event.id + "nutzap {} is on an unlisted mint: {}", + event.id, + nutzap.mint ); return; } @@ -624,8 +567,8 @@ impl WalletRegistry { self.redeeming.insert(event.id); self.schedule_job( ops::Job::RedeemNutzap { - mint: mint_url, - proofs, + mint: nutzap.mint, + proofs: nutzap.proofs, nutzap_id: event.id, sender: event.pubkey, }, @@ -661,7 +604,7 @@ impl WalletRegistry { } if let Some(store) = self.store.clone() { - let seed = match keys::derive_seed(&data.privkey) { + let seed = match keys::WalletData::derive_seed(&data.privkey) { Ok(seed) => seed, Err(error) => { self.emit_error(format!("{error:#}"), cx); @@ -703,7 +646,7 @@ impl WalletRegistry { let mints = data.mints.clone(); self.tasks.push(cx.spawn(async move |this, cx| { - match Tokio::spawn_result(cx, open_store(privkey, mints)).await { + match Tokio::spawn_result(cx, Self::open_store(privkey, mints)).await { Ok((store, wallets)) => { this.update(cx, |this, cx| { if this.store.is_none() { @@ -751,15 +694,16 @@ impl WalletRegistry { fn start_job(&mut self, job: ops::Job, cx: &mut Context) { if self.store.is_none() || self.wallets.is_empty() { - self.emit_error("Wallet is not ready".to_string(), cx); + self.job_queued.push(job); return; } let wallets = self.wallets.clone(); let tokens = self.tokens.clone(); - let signer = NostrRegistry::global(cx).read(cx).signer().clone(); - let client = NostrRegistry::global(cx).read(cx).client().clone(); + let nostr = NostrRegistry::global(cx); + let signer = nostr.read(cx).signer().clone(); + let client = nostr.read(cx).client().clone(); let wallet_key = match self .wallet @@ -789,7 +733,7 @@ impl WalletRegistry { tokens, }; - ops::run_job(job, ctx).await + job.run(ctx).await }) .await; @@ -827,6 +771,20 @@ impl WalletRegistry { self.emit_updated(cx); } } + ops::Outcome::QuoteCreated { + mint, + quote_id, + request, + amount, + } => { + self.quotes.push(MintQuoteEntry { + quote_id, + request, + amount, + mint, + }); + self.emit_updated(cx); + } ops::Outcome::QuoteMinted { quote_id, update, @@ -881,7 +839,6 @@ impl WalletRegistry { 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 @@ -903,7 +860,7 @@ impl WalletRegistry { let subscribe_client = client.clone(); let cached = cx.background_spawn(async move { let me = signer.get_public_key_async().await?; - let state = load_inner(&me, &client, &signer).await?; + let state = LoadedState::load(me, &client, &signer).await?; Ok::<_, Error>((me, state)) }); @@ -933,12 +890,11 @@ impl WalletRegistry { }) .ok(); - // 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?; - subscribe_client.subscribe(nutzap_filter(me)).await + subscribe_client.subscribe(Self::wallet_filter(me)).await?; + subscribe_client.subscribe(Self::nutzap_filter(me)).await }); + if let Err(error) = subscribed.await { log::warn!("wallet subscription failed: {error:#}"); } @@ -954,154 +910,163 @@ impl WalletRegistry { log::error!("wallet: {message}"); cx.emit(WalletEvent::Error(message)); } + + /// Open the cdk store and build a wallet per mint. + async fn open_store( + privkey: SecretKey, + mints: Vec, + ) -> Result<(Arc, HashMap), Error> { + let dir = config_dir() + .join("cashu") + .join(Keys::new(privkey.clone()).public_key().to_hex()); + std::fs::create_dir_all(&dir)?; + + let store = Arc::new(WalletRedbDatabase::new(&dir.join("wallet.redb"))?); + let seed = keys::WalletData::derive_seed(&privkey)?; + + let mut wallets = HashMap::new(); + for mint in mints { + let mint_url = MintUrl::from_str(mint.as_str())?; + let wallet = WalletBuilder::new() + .mint_url(mint_url.clone()) + .unit(CurrencyUnit::Sat) + .localstore(store.clone()) + .seed(seed) + .build()?; + wallets.insert(mint_url, wallet); + } + + Ok((store, wallets)) + } + + fn wallet_filter(author: PublicKey) -> Filter { + Filter::new().author(author).kinds([ + Kind::CashuWallet, + Kind::CashuWalletUnspentProof, + Kind::CashuWalletSpendingHistory, + Kind::EventDeletion, + ]) + } + + /// 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()) + } } -async fn load_inner(me: &PublicKey, client: &Client, signer: &S) -> Result -where - S: AsyncNip44 + ?Sized, -{ - let events = client.database().query(wallet_filter(*me)).await?; - let nutzap_events = client.database().query(nutzap_filter(*me)).await?; +impl LoadedState { + async fn load(me: PublicKey, client: &Client, signer: &S) -> Result + where + S: AsyncNip44 + ?Sized, + { + let events = client + .database() + .query(WalletRegistry::wallet_filter(me)) + .await?; + let nutzap_events = client + .database() + .query(WalletRegistry::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(); + 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 { - match event.kind { - Kind::CashuWallet => { - let replace = match &wallet_event { - Some(current) => event.created_at > current.created_at, - None => true, + for event in &events { + match event.kind { + Kind::CashuWallet => { + let replace = match &wallet_event { + Some(current) => event.created_at > current.created_at, + None => true, + }; + + if replace { + wallet_event = Some(event); + } + } + 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() { + if tag.kind() != "e" { + continue; + } + + let Some(hex) = tag.content() else { + continue; }; - if replace { - wallet_event = Some(event); + if let Ok(id) = EventId::from_hex(hex) { + stale.insert(id); } } - 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() { - if tag.kind() != "e" { + let mut tokens = HashMap::new(); + for event in token_events { + if stale.contains(&event.id) { continue; } - - let Some(hex) = tag.content() else { - continue; + let token = match keys::decrypt_token_event(signer, &me, event).await { + Ok(token) => token, + Err(error) => { + log::warn!("skipping unreadable token event {}: {error:#}", event.id); + continue; + } }; - - if let Ok(id) = EventId::from_hex(hex) { - stale.insert(id); - } + tokens.insert(event.id, token); } - } - let mut tokens = HashMap::new(); - for event in token_events { - if stale.contains(&event.id) { - continue; - } - let token = match keys::decrypt_token_event(signer, me, event).await { - Ok(token) => token, - Err(error) => { - log::warn!("skipping unreadable token event {}: {error:#}", event.id); - continue; - } + let superseded: HashSet = tokens + .values() + .flat_map(|token| token.del.iter()) + .filter_map(|hex| EventId::from_hex(hex).ok()) + .collect(); + + tokens.retain(|id, _| !stale.contains(id) && !superseded.contains(id)); + stale.extend(superseded); + + let wallet = match wallet_event { + Some(event) => match keys::WalletData::decrypt(signer, &me, event).await { + Ok(wallet) => Some(wallet), + Err(error) => { + log::warn!("skipping wallet event {}: {error:#}", event.id); + None + } + }, + None => None, }; - tokens.insert(event.id, token); + + Ok(Self { + wallet, + tokens, + stale, + redeemed, + nutzaps, + }) } - - let superseded: HashSet = tokens - .values() - .flat_map(|token| token.del.iter()) - .filter_map(|hex| EventId::from_hex(hex).ok()) - .collect(); - - tokens.retain(|id, _| !stale.contains(id) && !superseded.contains(id)); - stale.extend(superseded); - - let wallet = match wallet_event { - Some(event) => match keys::decrypt_wallet_event(signer, me, event).await { - Ok(wallet) => Some(wallet), - Err(error) => { - log::warn!("skipping wallet event {}: {error:#}", event.id); - None - } - }, - None => None, - }; - - Ok(LoadedState { - wallet, - tokens, - stale, - redeemed, - nutzaps, - }) -} - -async fn open_store( - privkey: SecretKey, - mints: Vec, -) -> Result<(Arc, HashMap), Error> { - let dir = config_dir() - .join("cashu") - .join(Keys::new(privkey.clone()).public_key().to_hex()); - std::fs::create_dir_all(&dir)?; - - let store = Arc::new(WalletRedbDatabase::new(&dir.join("wallet.redb"))?); - let seed = keys::derive_seed(&privkey)?; - - let mut wallets = HashMap::new(); - for mint in &mints { - let mint_url = MintUrl::from_str(mint.as_str())?; - let wallet = WalletBuilder::new() - .mint_url(mint_url.clone()) - .unit(CurrencyUnit::Sat) - .localstore(store.clone()) - .seed(seed) - .build()?; - wallets.insert(mint_url, wallet); - } - - Ok((store, wallets)) -} - -fn wallet_filter(author: PublicKey) -> Filter { - Filter::new().author(author).kinds([ - Kind::CashuWallet, - Kind::CashuWalletUnspentProof, - Kind::CashuWalletSpendingHistory, - Kind::EventDeletion, - ]) -} - -/// 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 { @@ -1112,57 +1077,6 @@ fn is_token_deletion(event: &Event) -> bool { .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 @@ -1176,3 +1090,59 @@ fn redeemed_ids(event: &Event) -> Vec { .filter_map(|hex| EventId::from_hex(hex).ok()) .collect() } + +/// A NIP-61 nutzap's proofs, validated against the wallet pubkey. +struct Nutzap { + mint: MintUrl, + proofs: Proofs, +} + +impl Nutzap { + fn from_event(event: &Event, wallet_target: &str) -> Result { + let mint = event + .tags + .iter() + .find(|tag| tag.kind() == "u" && tag.content().is_some()) + .and_then(|tag| tag.content()) + .ok_or_else(|| anyhow!("nutzap is missing the mint tag"))?; + let mint = MintUrl::from_str(mint).map_err(|error| anyhow!("invalid mint url: {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!("nutzap has no proofs")); + } + + let proofs = Proofs::from(proofs); + let locked = proofs.iter().all(|proof| locked_to(proof, wallet_target)); + if !locked { + return Err(anyhow!("proofs are not locked to the wallet key")); + } + + Ok(Self { mint, 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() + .is_some_and(|keys| keys.iter().any(|key| key.to_string() == target)) +} diff --git a/crates/nip60/src/ops.rs b/crates/nip60/src/ops.rs index eb8a5ab6..5e2e020e 100644 --- a/crates/nip60/src/ops.rs +++ b/crates/nip60/src/ops.rs @@ -22,6 +22,10 @@ use crate::keys; #[derive(Debug, Clone)] pub(crate) enum Job { Reconcile, + CreateQuote { + mint: MintUrl, + amount: u64, + }, ClaimQuote { mint: MintUrl, quote_id: String, @@ -46,14 +50,181 @@ pub(crate) enum Job { }, } -/// 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, +impl Job { + pub(crate) async fn run(self, ctx: JobCtx) -> Result + where + S: AsyncGetPublicKey + AsyncSignEvent + AsyncNip44 + Send + Sync + 'static, + { + match self { + Job::Reconcile => ctx.reconcile().await, + Job::CreateQuote { mint, amount } => { + let wallet = ctx.wallet(&mint)?; + let quote = wallet + .mint_quote( + PaymentMethod::Known(KnownMethod::Bolt11), + Some(Amount::from(amount)), + None, + None, + ) + .await?; + + Ok(Outcome::QuoteCreated { + mint, + quote_id: quote.id, + request: quote.request, + amount, + }) + } + Job::ClaimQuote { mint, quote_id } => { + let wallet = ctx.wallet(&mint)?; + let quote = wallet.check_mint_quote("e_id).await?; + + if quote.state != MintQuoteState::Paid { + return Ok(Outcome::Idle); + } + + let proofs = wallet.mint("e_id, SplitTarget::default(), None).await?; + let amount: u64 = proofs.iter().map(|proof| u64::from(proof.amount)).sum(); + + let update = ctx + .sync_token_events( + wallet, + nostr_mint(&mint)?, + &ctx.previous_ids(&mint), + Some(keys::History { + direction: TransactionDirection::In, + amount: u64::from(quote.amount.unwrap_or_default()), + redeemed: Vec::new(), + sender: None, + }), + ) + .await?; + + Ok(Outcome::QuoteMinted { + quote_id, + update, + amount, + }) + } + Job::Melt { mint, invoice } => { + let wallet = ctx.wallet(&mint)?; + let quote = wallet + .melt_quote( + PaymentMethod::Known(KnownMethod::Bolt11), + invoice, + None, + None, + ) + .await?; + + let finalized = wallet + .prepare_melt("e.id, HashMap::new()) + .await? + .confirm() + .await?; + + if finalized.state() != MeltQuoteState::Paid { + return Err(anyhow!("melt did not settle: {:?}", finalized.state())); + } + + let amount = u64::from(finalized.total_amount()); + let update = ctx + .sync_token_events( + wallet, + nostr_mint(&mint)?, + &ctx.previous_ids(&mint), + Some(keys::History { + direction: TransactionDirection::Out, + amount, + redeemed: Vec::new(), + sender: None, + }), + ) + .await?; + + Ok(Outcome::Melted { update, amount }) + } + Job::SendToken { mint, amount } => { + let wallet = ctx.wallet(&mint)?; + let prepared = wallet + .prepare_send(Amount::from(amount), SendOptions::default()) + .await?; + let token = prepared.confirm(None).await?.to_string(); + + let update = ctx + .sync_token_events( + wallet, + nostr_mint(&mint)?, + &ctx.previous_ids(&mint), + Some(keys::History { + direction: TransactionDirection::Out, + amount, + redeemed: Vec::new(), + sender: None, + }), + ) + .await?; + + Ok(Outcome::Sent { update, token }) + } + Job::Receive { mint, token } => { + let wallet = ctx.wallet(&mint)?; + let received = wallet.receive(&token, ReceiveOptions::default()).await?; + + let update = ctx + .sync_token_events( + wallet, + nostr_mint(&mint)?, + &ctx.previous_ids(&mint), + Some(keys::History { + direction: TransactionDirection::In, + amount: u64::from(received), + redeemed: Vec::new(), + sender: None, + }), + ) + .await?; + + Ok(Outcome::Received { + update, + amount: u64::from(received), + }) + } + Job::RedeemNutzap { + mint, + proofs, + nutzap_id, + sender, + } => { + let wallet = ctx.wallet(&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 = ctx + .sync_token_events( + wallet, + nostr_mint(&mint)?, + &ctx.previous_ids(&mint), + Some(keys::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), + }) + } + } + } } pub(crate) struct TokenUpdate { @@ -69,6 +240,12 @@ pub(crate) enum Outcome { Reconciled { updates: Vec, }, + QuoteCreated { + mint: MintUrl, + quote_id: String, + request: String, + amount: u64, + }, QuoteMinted { quote_id: String, update: TokenUpdate, @@ -103,295 +280,130 @@ pub(crate) struct JobCtx { pub(crate) tokens: HashMap, } -pub(crate) async fn run_job(job: Job, ctx: JobCtx) -> Result -where - S: AsyncGetPublicKey + AsyncSignEvent + AsyncNip44 + Send + Sync + 'static, -{ - match job { - Job::Reconcile => reconcile(ctx).await, - Job::ClaimQuote { mint, quote_id } => { - let wallet = wallet_for(&ctx, &mint)?; - let quote = wallet.check_mint_quote("e_id).await?; - - if quote.state != MintQuoteState::Paid { - return Ok(Outcome::Idle); - } - - let proofs = wallet.mint("e_id, SplitTarget::default(), None).await?; - let amount: u64 = proofs.iter().map(|proof| u64::from(proof.amount)).sum(); - - 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(quote.amount.unwrap_or_default()), - redeemed: Vec::new(), - sender: None, - }), - ) - .await?; - - Ok(Outcome::QuoteMinted { - quote_id, - update, - amount, - }) - } - Job::Melt { mint, invoice } => { - let wallet = wallet_for(&ctx, &mint)?; - let quote = wallet - .melt_quote( - PaymentMethod::Known(KnownMethod::Bolt11), - invoice, - None, - None, - ) - .await?; - - let finalized = wallet - .prepare_melt("e.id, HashMap::new()) - .await? - .confirm() - .await?; - - if finalized.state() != MeltQuoteState::Paid { - return Err(anyhow!("melt did not settle: {:?}", finalized.state())); - } - - let amount = u64::from(finalized.total_amount()); - let update = sync_token_events( - &ctx.signer, - &ctx.client, - wallet, - nostr_mint(&mint)?, - &previous_ids(&ctx.tokens, &mint), - Some(History { - direction: TransactionDirection::Out, - amount, - redeemed: Vec::new(), - sender: None, - }), - ) - .await?; - - Ok(Outcome::Melted { update, amount }) - } - Job::SendToken { mint, amount } => { - let wallet = wallet_for(&ctx, &mint)?; - let prepared = wallet - .prepare_send(Amount::from(amount), SendOptions::default()) - .await?; - let token = prepared.confirm(None).await?.to_string(); - - let update = sync_token_events( - &ctx.signer, - &ctx.client, - wallet, - nostr_mint(&mint)?, - &previous_ids(&ctx.tokens, &mint), - Some(History { - direction: TransactionDirection::Out, - amount, - redeemed: Vec::new(), - sender: None, - }), - ) - .await?; - - Ok(Outcome::Sent { update, token }) - } - Job::Receive { mint, token } => { - let wallet = wallet_for(&ctx, &mint)?; - let received = wallet.receive(&token, ReceiveOptions::default()).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::new(), - sender: None, - }), - ) - .await?; - - Ok(Outcome::Received { - update, - 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), - }) - } - } -} - -/// Claim paid quotes, then converge each mint. -async fn reconcile(ctx: JobCtx) -> Result +impl JobCtx where S: AsyncGetPublicKey + AsyncSignEvent + AsyncNip44 + Sync, { - let mut tokens = ctx.tokens.clone(); - let mut updates = Vec::new(); - - for (mint_url, wallet) in &ctx.wallets { - let minted = wallet.mint_unissued_quotes().await?; - if minted == Amount::ZERO { - continue; - } - - let update = sync_token_events( - &ctx.signer, - &ctx.client, - wallet, - nostr_mint(mint_url)?, - &previous_ids(&tokens, mint_url), - Some(History { - direction: TransactionDirection::In, - amount: u64::from(minted), - redeemed: Vec::new(), - sender: None, - }), - ) - .await?; - - apply_update(&mut tokens, &update); - updates.push(update); + fn wallet(&self, mint: &MintUrl) -> Result<&Wallet, Error> { + self.wallets + .get(mint) + .ok_or_else(|| anyhow!("no open wallet for mint {mint}")) } - for (mint_url, wallet) in &ctx.wallets { - let mut relay_ys = HashSet::new(); - let mut added = Vec::new(); - - for token in tokens - .values() - .filter(|token| token.mint.as_str() == mint_url.to_string()) - { - for proof in &token.proofs { - let info = match proof_info(proof, mint_url) { - Ok(info) => info, - Err(error) => { - log::warn!("skipping proof: {error:#}"); - continue; - } - }; - if relay_ys.insert(info.y) { - added.push(info); - } - } - } + fn previous_ids(&self, mint: &MintUrl) -> Vec { + previous_ids(&self.tokens, mint) + } + /// Publish the store's current unspent set as a fresh token event. + async fn sync_token_events( + &self, + wallet: &Wallet, + mint: Url, + previous: &[EventId], + history: Option, + ) -> Result { + let me = self.signer.get_public_key_async().await?; let unspent = wallet.get_unspent_proofs().await?; - let removed = unspent + + let proofs = unspent .iter() - .filter(|proof| match proof.y() { - Ok(y) => !relay_ys.contains(&y), - Err(_) => false, - }) - .map(|proof| proof.y()) + .map(cashu_proof) .collect::, _>>()?; - wallet.localstore.update_proofs(added, removed).await?; + let mut token = TokenEvent::new(mint, proofs); + token.del = previous.iter().map(|id| id.to_hex()).collect(); + let event = keys::build_token_event(&self.signer, &me, &token).await?; + self.client.send_event(&event).await?; + + if !previous.is_empty() { + let deletion = keys::build_token_deletion(&self.signer, previous).await?; + self.client.send_event(&deletion).await?; + } + + if let Some(history) = history { + let history_event = + keys::build_history_event(&self.signer, &me, &history, event.id, previous).await?; + self.client.send_event(&history_event).await?; + } + + Ok(TokenUpdate { + event_id: event.id, + token, + destroyed: previous.to_vec(), + }) } - Ok(Outcome::Reconciled { updates }) -} + /// Claim paid quotes, then converge each mint. + async fn reconcile(self) -> Result { + let mut tokens = self.tokens.clone(); + let mut updates = Vec::new(); -/// Publish the store's current unspent set as a fresh token event. -async fn sync_token_events( - signer: &S, - client: &Client, - wallet: &Wallet, - mint: Url, - previous: &[EventId], - history: Option, -) -> Result -where - S: AsyncGetPublicKey + AsyncSignEvent + AsyncNip44 + Sync + ?Sized, -{ - let me = signer.get_public_key_async().await?; - let unspent = wallet.get_unspent_proofs().await?; + for (mint_url, wallet) in &self.wallets { + wallet.recover_incomplete_sagas().await?; - let mut proofs = Vec::new(); - for proof in &unspent { - proofs.push(cashu_proof(proof)?); + let minted = wallet.mint_unissued_quotes().await?; + if minted == Amount::ZERO { + continue; + } + + let update = self + .sync_token_events( + wallet, + nostr_mint(mint_url)?, + &previous_ids(&tokens, mint_url), + Some(keys::History { + direction: TransactionDirection::In, + amount: u64::from(minted), + redeemed: Vec::new(), + sender: None, + }), + ) + .await?; + + for id in &update.destroyed { + tokens.remove(id); + } + tokens.insert(update.event_id, update.token.clone()); + updates.push(update); + } + + for (mint_url, wallet) in &self.wallets { + let mut relay_ys = HashSet::new(); + let mut added = Vec::new(); + + for token in tokens + .values() + .filter(|token| token.mint.as_str() == mint_url.to_string()) + { + for proof in &token.proofs { + let info = match proof_info(proof, mint_url) { + Ok(info) => info, + Err(error) => { + log::warn!("skipping proof: {error:#}"); + continue; + } + }; + if relay_ys.insert(info.y) { + added.push(info); + } + } + } + + let unspent = wallet.get_unspent_proofs().await?; + let removed = unspent + .iter() + .filter(|proof| match proof.y() { + Ok(y) => !relay_ys.contains(&y), + Err(_) => false, + }) + .map(|proof| proof.y()) + .collect::, _>>()?; + + wallet.localstore.update_proofs(added, removed).await?; + } + + Ok(Outcome::Reconciled { updates }) } - - let token = TokenEvent::new(mint, proofs); - let event = keys::build_token_event(signer, &me, &token).await?; - client.send_event(&event).await?; - - if !previous.is_empty() { - let deletion = keys::build_token_deletion(signer, previous).await?; - client.send_event(&deletion).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?; - } - - Ok(TokenUpdate { - event_id: event.id, - token, - destroyed: previous.to_vec(), - }) -} - -fn apply_update(tokens: &mut HashMap, update: &TokenUpdate) { - for id in &update.destroyed { - tokens.remove(id); - } - tokens.insert(update.event_id, update.token.clone()); } fn previous_ids(tokens: &HashMap, mint: &MintUrl) -> Vec { @@ -402,16 +414,6 @@ fn previous_ids(tokens: &HashMap, mint: &MintUrl) -> Vec(ctx: &'a JobCtx, mint: &MintUrl) -> Result<&'a Wallet, Error> { - ctx.wallets - .get(mint) - .ok_or_else(|| anyhow!("no open wallet for mint {mint}")) -} - -fn nostr_mint(mint: &MintUrl) -> Result { - Url::parse(&mint.to_string()).map_err(|error| anyhow!("invalid mint url: {error}")) -} - fn cashu_proof(proof: &Proof) -> Result { Ok(CashuProof { id: proof.keyset_id.to_string(), @@ -421,6 +423,10 @@ fn cashu_proof(proof: &Proof) -> Result { }) } +fn nostr_mint(mint: &MintUrl) -> Result { + Url::parse(&mint.to_string()).map_err(|error| anyhow!("invalid mint url: {error}")) +} + fn proof_info(proof: &CashuProof, mint: &MintUrl) -> Result { let keyset_id = Id::from_str(&proof.id)?; let c = cdk::nuts::PublicKey::from_str(&proof.c)?; diff --git a/docs/nip60.md b/docs/nip60.md index 91caac12..8c412721 100644 --- a/docs/nip60.md +++ b/docs/nip60.md @@ -143,43 +143,46 @@ Spike findings (all resolved and verified with `cargo check`): - One subscription: `authors: [me], kinds: [17375, 7375, 7376]` plus kind `5` with `k: 7375`, on the existing client (bootstrap relays). Notifications flow through the nip29-style flume channel. -- Published relays: the same client/relays coop already uses. Publishing a - `10019` wallet relay list with those relays is a one-liner for interop; - reading `10019` from other clients is skipped in v1 (coop always uses +- Published relays: the `10019` nutzap info event advertises the user's + NIP-65 relay list (queried from the local database), never the bootstrap + relays; subscriptions and publishes ride the SDK's NIP-65 handling. + Reading `10019` from other clients is skipped in v1 (coop always uses its own relay set). ### Operations Each op runs on tokio and ends with the same `sync` tail: read unspent proofs from cdk -> publish one `7375` per changed mint -(rolling up previous event ids into `del`) -> NIP-09 delete old events with +(with `del` set to the previous event ids) -> NIP-09 delete old events with a `k:7375` tag -> publish `7376` history -> update registry state and emit. - `setup(mint_url)`: create wallet key + seed, publish `17375`, build cdk `Wallet`, open subscription. -- `create_mint_quote(amount)`: `wallet.mint_quote` -> return invoice to UI; - poll / subscribe quote state, then `wallet.mint(quote_id)` -> sync. +- `create_mint_quote(amount)`: a job in the queue — `wallet.mint_quote` -> + invoice recorded in `mint_quotes()` for the UI; poll / + `claim_mint_quote(id)`, then `wallet.mint(quote_id)` -> sync. On load, `mint_unissued_quotes` claims quotes paid elsewhere. - `melt(invoice)`: `wallet.melt` -> sync (change proofs roll over). - `send_token(amount)`: `wallet.send` -> return token string for the user to share -> sync. (No in-chat token delivery in v1.) - `receive_token(token)`: `wallet.receive` -> sync. -- Redeem nutzap (NIP-61): verify the gift-wrapped proofs are P2PK-locked to +- Redeem nutzap (NIP-61): verify the proofs are P2PK-locked to the wallet pubkey, then `wallet.receive_proofs(proofs, ReceiveOptions { p2pk_signing_keys: [wallet_key] })` -> `7376` with unencrypted `e "" redeemed` tag -> sync. - Remote `7375` from another device: import its unspent proofs into the cdk store as the new local truth (cdk proof insertion + state check via `check_proofs_spent`); superseded `7375`s reconcile through `del`/NIP-09. -- On every load: `recover_incomplete_sagas` and `mint_unissued_quotes` - (required by cdk to clear interrupted operations), then re-derive the - unspent set from `7375` minus deletes. +- On every reconcile: `recover_incomplete_sagas` and + `mint_unissued_quotes` (required by cdk to clear interrupted operations), + then converge the unspent set from `7375` minus deletes. ### Crate layout (3 files, kept small on purpose) - `lib.rs` — `init`, `WalletRegistry`, `WalletEvent` (`Updated`, `Error`), subscription/notification handling, `reset` on signer change. -- `keys.rs` — wallet key/seed derivation, `17375` build/parse via signer. +- `keys.rs` — wallet key/seed derivation, `17375` build/parse via signer, + `7375`/deletion/`7376` event builders, the `History` payload. - `ops.rs` — cdk operations + the shared `sync` tail (rollover, deletes, history), NIP-61 redemption. @@ -277,6 +280,46 @@ Implemented: - Already-redeemed nutzaps (from cached `7376` tags) and ones with a job in flight are skipped; failed redemptions are retried on relay echo. +### Audit (post phase 3) — done + +Backend review before Phase 4: keep it simple, no premature edge-case +handling, struct/impl blocks over standalone functions. Applied: + +- Code shape: `keys::WalletData` carries generate/derive_seed/decrypt/ + to_event as an impl block; `ops::Job::run` and the `JobCtx` helpers + (`wallet`, `previous_ids`, `sync_token_events`, `reconcile`) are impl + methods; `LoadedState::load`, `WalletRegistry::open_store` and the + filters are associated functions; nutzap tag parsing moved into a + `Nutzap` struct (dropped the speculative `mint` tag fallback for the + current `u` tag). `History` moved to `keys.rs` so `build_history_event` + takes one struct instead of eight arguments. +- Spec gap: the sync tail never set `TokenEvent.del` — new `7375`s now + carry the previous event ids, so superseded tokens stay pruned even if + a NIP-09 delete is dropped by a relay. +- Bug: `start_job` errored "Wallet is not ready" for the auto `Reconcile` + scheduled before the redb store finished opening. Jobs scheduled while + the store is opening now queue and run once `ensure_wallets` completes; + `apply_loaded` no longer schedules a redundant `Reconcile`. +- Bug: a late-landing stale wallet decrypt could overwrite a newer + `17375`; `absorb_wallet` now re-checks `created_at` when applying. +- Consistency: an unreadable live token event logs a warning instead of + surfacing an error event (same as the load path); nutzaps arriving with + no wallet set up are dropped (a fresh setup would generate a different + key, so they were unredeemable anyway). +- Invariant: quote creation moved into the serialized job queue + (`Job::CreateQuote` / `Outcome::QuoteCreated`) — `mint_quote` writes to + the cdk store, so it must not interleave with running jobs. This also + collapsed the mint-resolution boilerplate shared by quote/melt/send + into `primary_mint`, and `MintQuoteEntry.mint` is now a `cdk::MintUrl`. +- Reconcile now calls `recover_incomplete_sagas` (the plan promised it; + it was missing), before `mint_unissued_quotes`. + +Not changed, deliberately: per-outcome `Outcome` variants, the bounded +notification dedup set, the single queued Reconcile collapse, and the +remaining small conversion helpers in `ops.rs` (`cashu_proof`, +`proof_info`, `nostr_mint`) — they are simple, and further abstraction +would be over-engineering. + ### Phase 4 — UI - Settings entry for the mint URL (`settings` crate pattern).