Skip to main content

nula_core/nips/
nip60.rs

1//! [NIP-60] Cashu Wallets — typed event bundles.
2//!
3//! NIP-60 stores a Cashu ecash wallet's state as a small set of
4//! encrypted Nostr events so the wallet follows the user across
5//! applications. Every kind in this module encrypts its payload to
6//! the **author's own** Nostr key (NIP-44 v2 self-encryption), so a
7//! relay only ever sees opaque ciphertext.
8//!
9//! | Kind   | Role                              | Replaceable |
10//! |--------|-----------------------------------|-------------|
11//! | 17375  | Wallet (mints + P2PK secret)      | ✓           |
12//! | 7375   | Unspent-token bundle              | —           |
13//! | 7376   | Spending history entry            | —           |
14//! | 7374   | Mint quote-id (optional)          | —           |
15//!
16//! # Why a typed module
17//!
18//! Upstream `rust-nostr` ships nothing for NIP-60. We model:
19//!
20//! 1. [`WalletInfo`] — the kind-17375 bundle (`mints` + optional
21//!    P2PK [`SecretKey`]), with a `to_event` / `from_event` round
22//!    trip that drives the NIP-44 self-encryption.
23//! 2. [`TokenContent`] — kind-7375 unspent-proof bundle with the
24//!    full Cashu BDHKE [`CashuProof`] shape (preserving the
25//!    spec-mandated uppercase `C` field via serde rename) plus the
26//!    optional `del` array used for state-transition rollovers.
27//! 3. [`HistoryEntry`] — kind-7376 entry with the typed
28//!    [`Direction`] (`in` / `out`) and three classes of `e`
29//!    references (`created`, `destroyed`, `redeemed`). The
30//!    `redeemed` references stay public per spec §"Spending History
31//!    Event".
32//! 4. [`QuoteState`] — kind-7374 ephemeral state for in-flight
33//!    Lightning mint quotes, carrying the spec-required
34//!    `expiration` (NIP-40) and `mint` tags in cleartext.
35//!
36//! Each bundle carries an `encrypt` and `decrypt` helper so callers
37//! can drive the NIP-44 round trip with their own key handling, and
38//! a matching [`EventBuilder`] method that wires the typed bundle
39//! into a signed event.
40//!
41//! [NIP-60]: https://github.com/nostr-protocol/nips/blob/master/60.md
42
43use serde::{Deserialize, Serialize};
44use thiserror::Error;
45
46use crate::event::{
47    Alphabet, Event, EventBuilder, EventBuilderError, EventId, EventIdError, Kind, SingleLetterTag,
48    Tag, TagError, TagKind, Tags,
49};
50use crate::key::{Keys, SecretKey, SecretKeyError};
51use crate::nips::nip40::EXPIRATION_TAG;
52use crate::nips::nip44;
53use crate::types::{Timestamp, Url, UrlError};
54
55/// `kind: 7374` — encrypted mint quote-id.
56pub const KIND_CASHU_QUOTE: Kind = Kind::CASHU_QUOTE;
57/// `kind: 7375` — unspent token bundle.
58pub const KIND_CASHU_TOKEN: Kind = Kind::CASHU_TOKEN;
59/// `kind: 7376` — spending history entry.
60pub const KIND_CASHU_HISTORY: Kind = Kind::CASHU_HISTORY;
61/// `kind: 17375` — replaceable wallet event.
62pub const KIND_CASHU_WALLET: Kind = Kind::CASHU_WALLET;
63
64mod tag_names {
65    pub(super) const PRIVKEY: &str = "privkey";
66    pub(super) const MINT: &str = "mint";
67    pub(super) const UNIT: &str = "unit";
68    pub(super) const AMOUNT: &str = "amount";
69    pub(super) const DIRECTION: &str = "direction";
70}
71
72mod history_markers {
73    pub(super) const CREATED: &str = "created";
74    pub(super) const DESTROYED: &str = "destroyed";
75    pub(super) const REDEEMED: &str = "redeemed";
76}
77
78/// Errors raised by the NIP-60 typed bundles.
79#[derive(Debug, Error)]
80#[non_exhaustive]
81pub enum Nip60Error {
82    /// Event kind did not match the expected NIP-60 kind.
83    #[error("expected kind {expected}, got {got}")]
84    WrongKind {
85        /// Kind the caller asked for.
86        expected: Kind,
87        /// Kind the event actually carried.
88        got: Kind,
89    },
90    /// Wallet bundle had zero `mint` rows.
91    #[error("NIP-60 wallet must declare at least one mint")]
92    NoMints,
93    /// History entry decoded without a `direction` row.
94    #[error("NIP-60 history entry missing `direction` row")]
95    MissingDirection,
96    /// History entry decoded without an `amount` row.
97    #[error("NIP-60 history entry missing `amount` row")]
98    MissingAmount,
99    /// History `e` row had no event id.
100    #[error("NIP-60 history `e` reference missing event id")]
101    MissingHistoryReference,
102    /// Quote event had no cleartext `mint` tag.
103    #[error("NIP-60 quote event missing `mint` tag")]
104    MissingMint,
105    /// Quote event had no NIP-40 `expiration` tag.
106    #[error("NIP-60 quote event missing NIP-40 `expiration` tag")]
107    MissingExpiration,
108    /// Quote event's `expiration` value was not a unix timestamp.
109    #[error("NIP-60 quote event `expiration` value is not a unix timestamp")]
110    MalformedExpiration,
111    /// JSON serialisation / deserialisation failed.
112    #[error(transparent)]
113    Json(#[from] serde_json::Error),
114    /// NIP-44 encrypt / decrypt failed.
115    #[error(transparent)]
116    Nip44(#[from] nip44::Nip44Error),
117    /// A `mint` URL was malformed.
118    #[error(transparent)]
119    Url(#[from] UrlError),
120    /// A `privkey` row was malformed hex.
121    #[error(transparent)]
122    SecretKey(#[from] SecretKeyError),
123    /// An `e` row's event id was malformed hex.
124    #[error(transparent)]
125    EventId(#[from] EventIdError),
126    /// A typed [`Tag`] could not be constructed.
127    #[error(transparent)]
128    Tag(#[from] TagError),
129    /// [`EventBuilder`] signing failed.
130    #[error(transparent)]
131    Builder(#[from] EventBuilderError),
132}
133
134/// Typed bundle for the replaceable `kind: 17375` wallet event.
135///
136/// `mints` MUST be non-empty per spec; [`Self::encrypt`] enforces
137/// the invariant via [`Nip60Error::NoMints`]. The optional `privkey`
138/// is the **wallet's own** P2PK secret (NOT the author's Nostr
139/// secret) and is only consumed by NIP-61 nutzaps.
140#[derive(Debug, Clone)]
141pub struct WalletInfo {
142    /// Mint URLs the wallet draws proofs from (≥ 1).
143    pub mints: Vec<Url>,
144    /// Optional P2PK secret used by NIP-61 nutzaps.
145    pub privkey: Option<SecretKey>,
146}
147
148impl WalletInfo {
149    /// Construct a wallet bundle from a non-empty mint list.
150    #[must_use]
151    pub const fn new(mints: Vec<Url>) -> Self {
152        Self {
153            mints,
154            privkey: None,
155        }
156    }
157
158    /// Attach a P2PK secret (used by NIP-61 nutzaps).
159    #[must_use]
160    pub fn with_privkey(mut self, privkey: SecretKey) -> Self {
161        self.privkey = Some(privkey);
162        self
163    }
164
165    fn to_inner_tags(&self) -> Vec<Vec<String>> {
166        let mut out: Vec<Vec<String>> = Vec::with_capacity(self.mints.len() + 1);
167        if let Some(pk) = &self.privkey {
168            out.push(vec![tag_names::PRIVKEY.to_owned(), pk.to_hex()]);
169        }
170        for mint in &self.mints {
171            out.push(vec![tag_names::MINT.to_owned(), mint.as_str().to_owned()]);
172        }
173        out
174    }
175
176    /// NIP-44 self-encrypt the bundle into a wire-ready ciphertext
177    /// the kind-17375 event will carry in `.content`.
178    ///
179    /// # Errors
180    ///
181    /// - [`Nip60Error::NoMints`] when [`Self::mints`] is empty.
182    /// - [`Nip60Error::Json`] when the inner tag array fails to
183    ///   serialise.
184    /// - [`Nip60Error::Nip44`] when the underlying primitive fails.
185    pub fn encrypt(&self, owner: &Keys) -> Result<String, Nip60Error> {
186        if self.mints.is_empty() {
187            return Err(Nip60Error::NoMints);
188        }
189        let json = serde_json::to_string(&self.to_inner_tags())?;
190        Ok(nip44::encrypt(
191            owner.secret_key(),
192            owner.public_key(),
193            &json,
194        )?)
195    }
196
197    /// Decrypt a kind-17375 `.content` payload back into a typed
198    /// bundle.
199    ///
200    /// # Errors
201    ///
202    /// Forwards every NIP-44 / JSON / URL / hex parse error.
203    pub fn decrypt(payload: &str, owner: &Keys) -> Result<Self, Nip60Error> {
204        let json = nip44::decrypt(owner.secret_key(), owner.public_key(), payload)?;
205        let raw: Vec<Vec<String>> = serde_json::from_str(&json)?;
206        let mut mints: Vec<Url> = Vec::new();
207        let mut privkey: Option<SecretKey> = None;
208        for row in raw {
209            let Some((head, rest)) = row.split_first() else {
210                continue;
211            };
212            let Some(value) = rest.first() else {
213                continue;
214            };
215            match head.as_str() {
216                tag_names::MINT => mints.push(Url::parse(value)?),
217                tag_names::PRIVKEY => privkey = Some(SecretKey::parse(value)?),
218                _ => {}
219            }
220        }
221        if mints.is_empty() {
222            return Err(Nip60Error::NoMints);
223        }
224        Ok(Self { mints, privkey })
225    }
226
227    /// Parse a signed kind-17375 event into a typed bundle.
228    ///
229    /// # Errors
230    ///
231    /// Returns [`Nip60Error::WrongKind`] when the event's kind is
232    /// not `17375`; otherwise forwards every error from
233    /// [`Self::decrypt`].
234    pub fn from_event(event: &Event, owner: &Keys) -> Result<Self, Nip60Error> {
235        if event.kind != KIND_CASHU_WALLET {
236            return Err(Nip60Error::WrongKind {
237                expected: KIND_CASHU_WALLET,
238                got: event.kind,
239            });
240        }
241        Self::decrypt(&event.content, owner)
242    }
243}
244
245/// A single Cashu proof in the standard BDHKE wire format.
246///
247/// The `c` field is serialised as the spec-mandated uppercase `C`
248/// per the Cashu mint API.
249#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
250pub struct CashuProof {
251    /// Keyset id (16-character hex of the mint's keyset).
252    pub id: String,
253    /// Amount denomination (mint-specific atomic unit).
254    pub amount: u64,
255    /// Random secret bound to the proof.
256    pub secret: String,
257    /// Unblinded signature point (uppercase `C` in the wire form).
258    #[serde(rename = "C")]
259    pub c: String,
260}
261
262/// Inner JSON payload of a kind-7375 token event.
263#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
264pub struct TokenContent {
265    /// URL of the mint these proofs belong to.
266    pub mint: String,
267    /// Base unit (`sat`, `usd`, …); spec default is `sat` when
268    /// omitted.
269    #[serde(default, skip_serializing_if = "Option::is_none")]
270    pub unit: Option<String>,
271    /// Unspent proofs.
272    pub proofs: Vec<CashuProof>,
273    /// Token event ids destroyed by the creation of this token (spec
274    /// §"Spending token" rollover).
275    #[serde(default, skip_serializing_if = "Vec::is_empty")]
276    pub del: Vec<String>,
277}
278
279impl TokenContent {
280    /// Build an unencrypted token bundle.
281    #[must_use]
282    pub fn new(mint: impl Into<String>, proofs: Vec<CashuProof>) -> Self {
283        Self {
284            mint: mint.into(),
285            unit: None,
286            proofs,
287            del: Vec::new(),
288        }
289    }
290
291    /// Set the unit field (`sat` / `usd` / `eur` / …).
292    #[must_use]
293    pub fn unit(mut self, unit: impl Into<String>) -> Self {
294        self.unit = Some(unit.into());
295        self
296    }
297
298    /// Mark token-event ids destroyed by this rollover.
299    #[must_use]
300    pub fn del(mut self, del: impl IntoIterator<Item = impl Into<String>>) -> Self {
301        self.del = del.into_iter().map(Into::into).collect();
302        self
303    }
304
305    /// Total amount across [`Self::proofs`].
306    #[must_use]
307    pub fn amount(&self) -> u64 {
308        self.proofs.iter().map(|p| p.amount).sum()
309    }
310
311    /// NIP-44 self-encrypt the bundle.
312    ///
313    /// # Errors
314    ///
315    /// Forwarded from JSON serialisation and [`crate::nips::nip44::encrypt`].
316    pub fn encrypt(&self, owner: &Keys) -> Result<String, Nip60Error> {
317        let json = serde_json::to_string(self)?;
318        Ok(nip44::encrypt(
319            owner.secret_key(),
320            owner.public_key(),
321            &json,
322        )?)
323    }
324
325    /// Decrypt a kind-7375 `.content` payload.
326    ///
327    /// # Errors
328    ///
329    /// Forwarded from [`crate::nips::nip44::decrypt`] and JSON parse.
330    pub fn decrypt(payload: &str, owner: &Keys) -> Result<Self, Nip60Error> {
331        let json = nip44::decrypt(owner.secret_key(), owner.public_key(), payload)?;
332        Ok(serde_json::from_str(&json)?)
333    }
334
335    /// Parse a signed kind-7375 event.
336    ///
337    /// # Errors
338    ///
339    /// Returns [`Nip60Error::WrongKind`] when the event's kind is
340    /// not `7375`; otherwise forwards every error from
341    /// [`Self::decrypt`].
342    pub fn from_event(event: &Event, owner: &Keys) -> Result<Self, Nip60Error> {
343        if event.kind != KIND_CASHU_TOKEN {
344            return Err(Nip60Error::WrongKind {
345                expected: KIND_CASHU_TOKEN,
346                got: event.kind,
347            });
348        }
349        Self::decrypt(&event.content, owner)
350    }
351}
352
353/// Direction column of [`HistoryEntry`].
354#[derive(Debug, Clone, Copy, PartialEq, Eq)]
355#[non_exhaustive]
356pub enum Direction {
357    /// Funds received (`in`).
358    In,
359    /// Funds sent (`out`).
360    Out,
361}
362
363impl Direction {
364    /// Wire-form string (`in` / `out`).
365    #[must_use]
366    pub const fn as_str(self) -> &'static str {
367        match self {
368            Self::In => "in",
369            Self::Out => "out",
370        }
371    }
372
373    /// Parse the wire-form string. Returns `None` for unknown values.
374    #[must_use]
375    pub const fn from_wire(s: &str) -> Option<Self> {
376        match s.as_bytes() {
377            b"in" => Some(Self::In),
378            b"out" => Some(Self::Out),
379            _ => None,
380        }
381    }
382}
383
384/// Spending-history entry (kind 7376).
385///
386/// `created` and `destroyed` references SHOULD stay encrypted with
387/// the rest of the entry; `redeemed` references SHOULD be left as
388/// public tags so wallets can match nutzap redemptions without
389/// decrypting every history entry first (spec §"Spending History
390/// Event").
391#[derive(Debug, Clone, PartialEq, Eq)]
392pub struct HistoryEntry {
393    /// `in` / `out` direction.
394    pub direction: Direction,
395    /// Amount the wallet's balance changed by.
396    pub amount: u64,
397    /// Optional unit (`sat` default).
398    pub unit: Option<String>,
399    /// Token events created in this transaction (encrypted).
400    pub created: Vec<EventId>,
401    /// Token events destroyed in this transaction (encrypted).
402    pub destroyed: Vec<EventId>,
403    /// Nutzap events redeemed in this transaction (public).
404    pub redeemed: Vec<EventId>,
405}
406
407impl HistoryEntry {
408    /// Construct a history entry with empty `e`-tag lists.
409    #[must_use]
410    pub const fn new(direction: Direction, amount: u64) -> Self {
411        Self {
412            direction,
413            amount,
414            unit: None,
415            created: Vec::new(),
416            destroyed: Vec::new(),
417            redeemed: Vec::new(),
418        }
419    }
420
421    /// Set [`Self::unit`].
422    #[must_use]
423    pub fn unit(mut self, unit: impl Into<String>) -> Self {
424        self.unit = Some(unit.into());
425        self
426    }
427
428    /// Append a `created` token reference.
429    #[must_use]
430    pub fn created(mut self, id: EventId) -> Self {
431        self.created.push(id);
432        self
433    }
434
435    /// Append a `destroyed` token reference.
436    #[must_use]
437    pub fn destroyed(mut self, id: EventId) -> Self {
438        self.destroyed.push(id);
439        self
440    }
441
442    /// Append a `redeemed` nutzap reference.
443    #[must_use]
444    pub fn redeemed(mut self, id: EventId) -> Self {
445        self.redeemed.push(id);
446        self
447    }
448
449    fn encrypted_rows(&self) -> Vec<Vec<String>> {
450        let mut rows: Vec<Vec<String>> =
451            Vec::with_capacity(3 + self.created.len() + self.destroyed.len());
452        rows.push(vec![
453            tag_names::DIRECTION.to_owned(),
454            self.direction.as_str().to_owned(),
455        ]);
456        rows.push(vec![tag_names::AMOUNT.to_owned(), self.amount.to_string()]);
457        if let Some(unit) = &self.unit {
458            rows.push(vec![tag_names::UNIT.to_owned(), unit.clone()]);
459        }
460        for id in &self.created {
461            rows.push(vec![
462                "e".to_owned(),
463                id.to_hex(),
464                String::new(),
465                history_markers::CREATED.to_owned(),
466            ]);
467        }
468        for id in &self.destroyed {
469            rows.push(vec![
470                "e".to_owned(),
471                id.to_hex(),
472                String::new(),
473                history_markers::DESTROYED.to_owned(),
474            ]);
475        }
476        rows
477    }
478
479    /// Public-half tags (the `redeemed` references plus nothing else).
480    #[must_use]
481    pub fn public_tags(&self) -> Vec<Tag> {
482        let kind = TagKind::single_letter(SingleLetterTag::lowercase(Alphabet::E));
483        let mut out: Vec<Tag> = Vec::with_capacity(self.redeemed.len());
484        for id in &self.redeemed {
485            out.push(Tag::with(
486                &kind,
487                [
488                    id.to_hex(),
489                    String::new(),
490                    history_markers::REDEEMED.to_owned(),
491                ],
492            ));
493        }
494        out
495    }
496
497    /// NIP-44 self-encrypt the encrypted half of the entry.
498    ///
499    /// # Errors
500    ///
501    /// Forwarded from JSON serialisation and [`crate::nips::nip44::encrypt`].
502    pub fn encrypt(&self, owner: &Keys) -> Result<String, Nip60Error> {
503        let json = serde_json::to_string(&self.encrypted_rows())?;
504        Ok(nip44::encrypt(
505            owner.secret_key(),
506            owner.public_key(),
507            &json,
508        )?)
509    }
510
511    /// Reverse [`Self::encrypt`] + the public `redeemed` tags into a
512    /// typed entry.
513    ///
514    /// # Errors
515    ///
516    /// Forwards every NIP-44 / JSON / event-id parse error.
517    pub fn decrypt(
518        encrypted_payload: &str,
519        public_tags: &Tags,
520        owner: &Keys,
521    ) -> Result<Self, Nip60Error> {
522        let json = nip44::decrypt(owner.secret_key(), owner.public_key(), encrypted_payload)?;
523        let rows: Vec<Vec<String>> = serde_json::from_str(&json)?;
524
525        let mut direction: Option<Direction> = None;
526        let mut amount: Option<u64> = None;
527        let mut unit: Option<String> = None;
528        let mut created: Vec<EventId> = Vec::new();
529        let mut destroyed: Vec<EventId> = Vec::new();
530
531        for row in rows {
532            ingest_encrypted_row(
533                &row,
534                &mut direction,
535                &mut amount,
536                &mut unit,
537                &mut created,
538                &mut destroyed,
539            )?;
540        }
541
542        let direction = direction.ok_or(Nip60Error::MissingDirection)?;
543        let amount = amount.ok_or(Nip60Error::MissingAmount)?;
544
545        let mut redeemed: Vec<EventId> = Vec::new();
546        for tag in public_tags {
547            if tag.name() != "e" {
548                continue;
549            }
550            // `values()` returns the *full* row (head + args). The
551            // marker therefore lives at index 3, the event id at
552            // index 1.
553            let values = tag.values();
554            let marker = values.get(3).map(String::as_str).unwrap_or_default();
555            if marker != history_markers::REDEEMED {
556                continue;
557            }
558            let id_hex = values.get(1).ok_or(Nip60Error::MissingHistoryReference)?;
559            redeemed.push(EventId::parse(id_hex)?);
560        }
561
562        Ok(Self {
563            direction,
564            amount,
565            unit,
566            created,
567            destroyed,
568            redeemed,
569        })
570    }
571
572    /// Parse a signed kind-7376 event.
573    ///
574    /// # Errors
575    ///
576    /// Returns [`Nip60Error::WrongKind`] when the event's kind is
577    /// not `7376`; otherwise forwards every error from
578    /// [`Self::decrypt`].
579    pub fn from_event(event: &Event, owner: &Keys) -> Result<Self, Nip60Error> {
580        if event.kind != KIND_CASHU_HISTORY {
581            return Err(Nip60Error::WrongKind {
582                expected: KIND_CASHU_HISTORY,
583                got: event.kind,
584            });
585        }
586        Self::decrypt(&event.content, &event.tags, owner)
587    }
588}
589
590/// Optional quote-state event (`kind: 7374`).
591///
592/// Carries an in-flight Lightning mint quote-id encrypted to the
593/// author, plus the cleartext `mint` and [NIP-40] `expiration`
594/// columns the spec mandates so other clients can prune the event
595/// once the quote has settled.
596///
597/// [NIP-40]: https://github.com/nostr-protocol/nips/blob/master/40.md
598#[derive(Debug, Clone, PartialEq, Eq)]
599pub struct QuoteState {
600    /// Mint URL the quote was opened against.
601    pub mint: Url,
602    /// Quote id returned by the mint (encrypted in `.content`).
603    pub quote_id: String,
604    /// NIP-40 expiration timestamp (spec hard-codes ~2 weeks).
605    pub expiration: Timestamp,
606}
607
608impl QuoteState {
609    /// Construct a quote-state bundle.
610    #[must_use]
611    pub fn new(mint: Url, quote_id: impl Into<String>, expiration: Timestamp) -> Self {
612        Self {
613            mint,
614            quote_id: quote_id.into(),
615            expiration,
616        }
617    }
618
619    /// NIP-44 self-encrypt the [`Self::quote_id`].
620    ///
621    /// # Errors
622    ///
623    /// Forwarded from [`crate::nips::nip44::encrypt`].
624    pub fn encrypt(&self, owner: &Keys) -> Result<String, Nip60Error> {
625        Ok(nip44::encrypt(
626            owner.secret_key(),
627            owner.public_key(),
628            &self.quote_id,
629        )?)
630    }
631
632    /// Render the cleartext public tags `[expiration, mint]`.
633    #[must_use]
634    pub fn to_tags(&self) -> Vec<Tag> {
635        vec![
636            Tag::with(
637                &TagKind::from_wire(EXPIRATION_TAG),
638                [self.expiration.as_secs().to_string()],
639            ),
640            Tag::with(
641                &TagKind::custom(tag_names::MINT),
642                [self.mint.as_str().to_owned()],
643            ),
644        ]
645    }
646
647    /// Parse a signed kind-7374 event.
648    ///
649    /// # Errors
650    ///
651    /// - [`Nip60Error::WrongKind`] when the event's kind is not `7374`.
652    /// - [`Nip60Error::MissingExpiration`] when no NIP-40 expiration
653    ///   tag is present.
654    /// - [`Nip60Error::MissingMint`] when no `mint` tag is present.
655    /// - Forwarded from [`crate::nips::nip44::decrypt`] for the
656    ///   inner quote-id.
657    pub fn from_event(event: &Event, owner: &Keys) -> Result<Self, Nip60Error> {
658        if event.kind != KIND_CASHU_QUOTE {
659            return Err(Nip60Error::WrongKind {
660                expected: KIND_CASHU_QUOTE,
661                got: event.kind,
662            });
663        }
664        let mut mint: Option<Url> = None;
665        let mut expiration: Option<Timestamp> = None;
666        for tag in &event.tags {
667            // `values()` includes the tag head; the first argument
668            // therefore lives at index 1.
669            let Some(value) = tag.values().get(1) else {
670                continue;
671            };
672            match tag.name() {
673                tag_names::MINT => mint = Some(Url::parse(value)?),
674                EXPIRATION_TAG => {
675                    let secs: u64 = value.parse().map_err(|_| Nip60Error::MalformedExpiration)?;
676                    expiration = Some(Timestamp::from_secs(secs));
677                }
678                _ => {}
679            }
680        }
681        let mint = mint.ok_or(Nip60Error::MissingMint)?;
682        let expiration = expiration.ok_or(Nip60Error::MissingExpiration)?;
683        let quote_id = nip44::decrypt(owner.secret_key(), owner.public_key(), &event.content)?;
684        Ok(Self {
685            mint,
686            quote_id,
687            expiration,
688        })
689    }
690}
691
692impl EventBuilder {
693    /// Author a NIP-60 wallet event (`kind: 17375`) from a typed
694    /// [`WalletInfo`].
695    ///
696    /// The bundle is NIP-44 self-encrypted to `owner` before being
697    /// stamped on the new event's `.content`.
698    ///
699    /// # Errors
700    ///
701    /// Forwards every error from [`WalletInfo::encrypt`].
702    pub fn cashu_wallet(info: &WalletInfo, owner: &Keys) -> Result<Self, Nip60Error> {
703        let payload = info.encrypt(owner)?;
704        Ok(Self::new(KIND_CASHU_WALLET, payload))
705    }
706
707    /// Author a NIP-60 token event (`kind: 7375`) from a typed
708    /// [`TokenContent`].
709    ///
710    /// # Errors
711    ///
712    /// Forwards every error from [`TokenContent::encrypt`].
713    pub fn cashu_token(token: &TokenContent, owner: &Keys) -> Result<Self, Nip60Error> {
714        let payload = token.encrypt(owner)?;
715        Ok(Self::new(KIND_CASHU_TOKEN, payload))
716    }
717
718    /// Author a NIP-60 spending-history event (`kind: 7376`) from a
719    /// typed [`HistoryEntry`].
720    ///
721    /// The encrypted half — the direction, amount, unit, and the
722    /// created plus destroyed references — goes into `.content`;
723    /// the public `redeemed` references are stamped as cleartext
724    /// `e` tags so nutzap recipients can match them without
725    /// decryption.
726    ///
727    /// # Errors
728    ///
729    /// Forwards every error from [`HistoryEntry::encrypt`].
730    pub fn cashu_history(entry: &HistoryEntry, owner: &Keys) -> Result<Self, Nip60Error> {
731        let payload = entry.encrypt(owner)?;
732        let mut builder = Self::new(KIND_CASHU_HISTORY, payload);
733        for tag in entry.public_tags() {
734            builder = builder.tag(tag);
735        }
736        Ok(builder)
737    }
738
739    /// Author a NIP-60 quote-state event (`kind: 7374`) from a typed
740    /// [`QuoteState`].
741    ///
742    /// The quote id is encrypted into `.content`; the `mint` and
743    /// NIP-40 `expiration` tags are stamped in cleartext per spec.
744    ///
745    /// # Errors
746    ///
747    /// Forwards every error from [`QuoteState::encrypt`].
748    pub fn cashu_quote(quote: &QuoteState, owner: &Keys) -> Result<Self, Nip60Error> {
749        let payload = quote.encrypt(owner)?;
750        let mut builder = Self::new(KIND_CASHU_QUOTE, payload);
751        for tag in quote.to_tags() {
752            builder = builder.tag(tag);
753        }
754        Ok(builder)
755    }
756}
757
758/// Internal dispatch routine for [`HistoryEntry::decrypt`].
759///
760/// Pulled out into a freestanding fn so the calling loop body stays
761/// flat; clippy's `excessive_nesting` lint flagged the inlined
762/// version as too deep.
763fn ingest_encrypted_row(
764    row: &[String],
765    direction: &mut Option<Direction>,
766    amount: &mut Option<u64>,
767    unit: &mut Option<String>,
768    created: &mut Vec<EventId>,
769    destroyed: &mut Vec<EventId>,
770) -> Result<(), Nip60Error> {
771    let Some((head, rest)) = row.split_first() else {
772        return Ok(());
773    };
774    match head.as_str() {
775        tag_names::DIRECTION => {
776            if let Some(v) = rest.first() {
777                *direction = Direction::from_wire(v);
778            }
779        }
780        tag_names::AMOUNT => {
781            if let Some(v) = rest.first() {
782                *amount = v.parse().ok();
783            }
784        }
785        tag_names::UNIT => {
786            *unit = rest.first().cloned();
787        }
788        "e" => {
789            let id_hex = rest.first().ok_or(Nip60Error::MissingHistoryReference)?;
790            let id = EventId::parse(id_hex)?;
791            let marker = rest.get(2).map(String::as_str).unwrap_or_default();
792            match marker {
793                history_markers::CREATED => created.push(id),
794                history_markers::DESTROYED => destroyed.push(id),
795                // `redeemed` markers are expected on public tags
796                // rather than the encrypted body; tolerate their
797                // presence here for forward compatibility with
798                // clients that chose to encrypt them anyway.
799                _ => {}
800            }
801        }
802        _ => {}
803    }
804    Ok(())
805}
806
807#[cfg(test)]
808mod tests {
809    use super::*;
810
811    fn keys() -> Keys {
812        Keys::parse("0000000000000000000000000000000000000000000000000000000000000003").unwrap()
813    }
814
815    fn other_keys() -> Keys {
816        Keys::parse("0000000000000000000000000000000000000000000000000000000000000005").unwrap()
817    }
818
819    fn mint() -> Url {
820        Url::parse("https://stablenut.umint.cash").unwrap()
821    }
822
823    fn second_mint() -> Url {
824        Url::parse("https://mint.example/").unwrap()
825    }
826
827    fn fixture_proof(amount: u64, secret: &str) -> CashuProof {
828        CashuProof {
829            id: "005c2502034d4f12".to_owned(),
830            amount,
831            secret: secret.to_owned(),
832            c: "0241d98a8197ef238a192d47edf191a9de78b657308937b4f7dd0aa53beae72c46".to_owned(),
833        }
834    }
835
836    #[test]
837    fn wallet_round_trips_through_encrypt_decrypt() {
838        let owner = keys();
839        let info = WalletInfo::new(vec![mint(), second_mint()])
840            .with_privkey(other_keys().secret_key().clone());
841        let payload = info.encrypt(&owner).unwrap();
842        let recovered = WalletInfo::decrypt(&payload, &owner).unwrap();
843        assert_eq!(recovered.mints, info.mints);
844        assert_eq!(
845            recovered.privkey.as_ref().map(SecretKey::to_hex),
846            info.privkey.as_ref().map(SecretKey::to_hex),
847        );
848    }
849
850    #[test]
851    fn wallet_encrypt_rejects_empty_mints() {
852        let owner = keys();
853        let info = WalletInfo::new(Vec::new());
854        assert!(matches!(info.encrypt(&owner), Err(Nip60Error::NoMints)));
855    }
856
857    #[test]
858    fn wallet_from_event_rejects_wrong_kind() {
859        let owner = keys();
860        let info = WalletInfo::new(vec![mint()]);
861        let payload = info.encrypt(&owner).unwrap();
862        let event = EventBuilder::new(Kind::TEXT_NOTE, payload)
863            .sign_with_keys(&owner)
864            .unwrap();
865        assert!(matches!(
866            WalletInfo::from_event(&event, &owner),
867            Err(Nip60Error::WrongKind { .. })
868        ));
869    }
870
871    #[test]
872    fn wallet_event_round_trips() {
873        let owner = keys();
874        let info = WalletInfo::new(vec![mint()]).with_privkey(other_keys().secret_key().clone());
875        let event = EventBuilder::cashu_wallet(&info, &owner)
876            .unwrap()
877            .sign_with_keys(&owner)
878            .unwrap();
879        assert_eq!(event.kind, KIND_CASHU_WALLET);
880        let recovered = WalletInfo::from_event(&event, &owner).unwrap();
881        assert_eq!(recovered.mints, info.mints);
882    }
883
884    #[test]
885    fn token_round_trips_through_encrypt_decrypt() {
886        let owner = keys();
887        let token = TokenContent::new(
888            mint().as_str(),
889            vec![
890                fixture_proof(1, "z+zyxAVLRqN9lEjxuNPSyRJzEstbl69Jc1vtimvtkPg="),
891                fixture_proof(2, "z+zyxAVLRqN9lEjxuNPSyRJzEstbl69Jc1vtimvtkPa="),
892            ],
893        )
894        .unit("sat")
895        .del(["aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"]);
896        let payload = token.encrypt(&owner).unwrap();
897        let recovered = TokenContent::decrypt(&payload, &owner).unwrap();
898        assert_eq!(recovered, token);
899        assert_eq!(recovered.amount(), 3);
900    }
901
902    #[test]
903    fn token_proof_serializes_uppercase_c() {
904        let proof = fixture_proof(8, "secret");
905        let json = serde_json::to_string(&proof).unwrap();
906        assert!(json.contains("\"C\":"), "wire form must use uppercase C");
907        assert!(!json.contains("\"c\":"), "lowercase c MUST NOT appear");
908    }
909
910    #[test]
911    fn token_event_round_trips_via_event_builder() {
912        let owner = keys();
913        let token = TokenContent::new(mint().as_str(), vec![fixture_proof(4, "abc")]);
914        let event = EventBuilder::cashu_token(&token, &owner)
915            .unwrap()
916            .sign_with_keys(&owner)
917            .unwrap();
918        assert_eq!(event.kind, KIND_CASHU_TOKEN);
919        let recovered = TokenContent::from_event(&event, &owner).unwrap();
920        assert_eq!(recovered, token);
921    }
922
923    #[test]
924    fn token_from_event_rejects_wrong_kind() {
925        let owner = keys();
926        let token = TokenContent::new(mint().as_str(), vec![fixture_proof(1, "x")]);
927        let payload = token.encrypt(&owner).unwrap();
928        let event = EventBuilder::new(Kind::TEXT_NOTE, payload)
929            .sign_with_keys(&owner)
930            .unwrap();
931        assert!(matches!(
932            TokenContent::from_event(&event, &owner),
933            Err(Nip60Error::WrongKind { .. })
934        ));
935    }
936
937    #[test]
938    fn direction_round_trips_through_wire_form() {
939        assert_eq!(Direction::In.as_str(), "in");
940        assert_eq!(Direction::Out.as_str(), "out");
941        assert_eq!(Direction::from_wire("in"), Some(Direction::In));
942        assert_eq!(Direction::from_wire("out"), Some(Direction::Out));
943        assert_eq!(Direction::from_wire("INVALID"), None);
944    }
945
946    #[test]
947    fn history_round_trips_with_public_redeemed_tag() {
948        let owner = keys();
949        let created_id = EventId::from_byte_array([0xaa; 32]);
950        let destroyed_id = EventId::from_byte_array([0xbb; 32]);
951        let redeemed_id = EventId::from_byte_array([0xcc; 32]);
952        let entry = HistoryEntry::new(Direction::Out, 4)
953            .unit("sat")
954            .created(created_id)
955            .destroyed(destroyed_id)
956            .redeemed(redeemed_id);
957
958        let event = EventBuilder::cashu_history(&entry, &owner)
959            .unwrap()
960            .sign_with_keys(&owner)
961            .unwrap();
962        assert_eq!(event.kind, KIND_CASHU_HISTORY);
963
964        // The `redeemed` tag MUST stay in the cleartext public tag
965        // set per spec §"Spending History Event". `values()` returns
966        // the full row including the head, so the marker lives at
967        // index 3.
968        let public_redeemed_count = event
969            .tags
970            .iter()
971            .filter(|t| t.name() == "e")
972            .filter(|t| t.values().get(3).map(String::as_str) == Some("redeemed"))
973            .count();
974        assert_eq!(public_redeemed_count, 1);
975
976        let recovered = HistoryEntry::from_event(&event, &owner).unwrap();
977        assert_eq!(recovered, entry);
978    }
979
980    #[test]
981    fn history_from_event_rejects_wrong_kind() {
982        let owner = keys();
983        let entry = HistoryEntry::new(Direction::In, 1);
984        let payload = entry.encrypt(&owner).unwrap();
985        let event = EventBuilder::new(Kind::TEXT_NOTE, payload)
986            .sign_with_keys(&owner)
987            .unwrap();
988        assert!(matches!(
989            HistoryEntry::from_event(&event, &owner),
990            Err(Nip60Error::WrongKind { .. })
991        ));
992    }
993
994    #[test]
995    fn quote_round_trips_through_event_builder() {
996        let owner = keys();
997        let quote = QuoteState::new(mint(), "abc-quote-id", Timestamp::from_secs(1_700_000_000));
998
999        let event = EventBuilder::cashu_quote(&quote, &owner)
1000            .unwrap()
1001            .sign_with_keys(&owner)
1002            .unwrap();
1003        assert_eq!(event.kind, KIND_CASHU_QUOTE);
1004
1005        // Cleartext envelope must carry both the spec-required tags.
1006        let mint_tag = event.tags.iter().any(|t| t.name() == "mint");
1007        let expiration_tag = event.tags.iter().any(|t| t.name() == "expiration");
1008        assert!(mint_tag);
1009        assert!(expiration_tag);
1010
1011        let recovered = QuoteState::from_event(&event, &owner).unwrap();
1012        assert_eq!(recovered, quote);
1013    }
1014
1015    #[test]
1016    fn quote_from_event_requires_mint_and_expiration() {
1017        let owner = keys();
1018        let payload = nip44::encrypt(owner.secret_key(), owner.public_key(), "quote").unwrap();
1019        let no_tags = EventBuilder::new(KIND_CASHU_QUOTE, payload.clone())
1020            .sign_with_keys(&owner)
1021            .unwrap();
1022        assert!(matches!(
1023            QuoteState::from_event(&no_tags, &owner),
1024            Err(Nip60Error::MissingMint),
1025        ));
1026
1027        let only_mint = EventBuilder::new(KIND_CASHU_QUOTE, payload)
1028            .tag(Tag::with(
1029                &TagKind::custom(tag_names::MINT),
1030                [mint().as_str().to_owned()],
1031            ))
1032            .sign_with_keys(&owner)
1033            .unwrap();
1034        assert!(matches!(
1035            QuoteState::from_event(&only_mint, &owner),
1036            Err(Nip60Error::MissingExpiration),
1037        ));
1038    }
1039}