1use thiserror::Error;
36
37use crate::event::{
38 Alphabet, Event, EventBuilder, EventBuilderError, EventId, EventIdError, Kind, SingleLetterTag,
39 Tag, TagError, TagKind,
40};
41use crate::key::{PublicKey, PublicKeyError};
42use crate::nips::nip60::CashuProof;
43use crate::types::{RelayUrl, RelayUrlError, Url, UrlError};
44
45pub const KIND_NUTZAP: Kind = Kind::NUTZAP;
47pub const KIND_NUTZAP_INFO: Kind = Kind::NUTZAP_INFO;
49
50mod tag_names {
51 pub(super) const RELAY: &str = "relay";
52 pub(super) const MINT: &str = "mint";
53 pub(super) const PUBKEY: &str = "pubkey";
54 pub(super) const PROOF: &str = "proof";
55 pub(super) const UNIT: &str = "unit";
56 pub(super) const U: &str = "u";
57 pub(super) const K: &str = "k";
58}
59
60#[derive(Debug, Error)]
62#[non_exhaustive]
63pub enum Nip61Error {
64 #[error("expected kind {expected}, got {got}")]
66 WrongKind {
67 expected: Kind,
69 got: Kind,
71 },
72 #[error("NIP-61 informational event must list at least one mint")]
75 NoMints,
76 #[error("NIP-61 informational event missing `pubkey` row")]
79 MissingPubkey,
80 #[error("NIP-61 nutzap event must carry at least one `proof` tag")]
82 NoProofs,
83 #[error("NIP-61 nutzap event missing `u` mint tag")]
85 MissingMintUrl,
86 #[error("NIP-61 nutzap event missing recipient `p` tag")]
88 MissingRecipient,
89 #[error(transparent)]
92 Json(#[from] serde_json::Error),
93 #[error(transparent)]
95 RelayUrl(#[from] RelayUrlError),
96 #[error(transparent)]
98 Url(#[from] UrlError),
99 #[error(transparent)]
101 PublicKey(#[from] PublicKeyError),
102 #[error(transparent)]
104 EventId(#[from] EventIdError),
105 #[error("NIP-61 nutzap `k` tag is not a valid kind integer")]
107 MalformedKind,
108 #[error(transparent)]
110 Tag(#[from] TagError),
111 #[error(transparent)]
113 Builder(#[from] EventBuilderError),
114}
115
116#[derive(Debug, Clone, PartialEq, Eq)]
122pub struct NutzapMint {
123 pub url: Url,
125 pub units: Vec<String>,
128}
129
130impl NutzapMint {
131 #[must_use]
133 pub const fn new(url: Url) -> Self {
134 Self {
135 url,
136 units: Vec::new(),
137 }
138 }
139
140 #[must_use]
142 pub fn unit(mut self, unit: impl Into<String>) -> Self {
143 self.units.push(unit.into());
144 self
145 }
146}
147
148#[derive(Debug, Clone, PartialEq, Eq)]
151pub struct NutzapInfo {
152 pub relays: Vec<RelayUrl>,
155 pub mints: Vec<NutzapMint>,
157 pub pubkey: PublicKey,
160}
161
162impl NutzapInfo {
163 #[must_use]
165 pub const fn new(pubkey: PublicKey, mints: Vec<NutzapMint>, relays: Vec<RelayUrl>) -> Self {
166 Self {
167 relays,
168 mints,
169 pubkey,
170 }
171 }
172
173 pub fn to_tags(&self) -> Result<Vec<Tag>, Nip61Error> {
180 if self.mints.is_empty() {
181 return Err(Nip61Error::NoMints);
182 }
183 let mut tags: Vec<Tag> = Vec::with_capacity(self.relays.len() + self.mints.len() + 1);
184 for relay in &self.relays {
185 tags.push(Tag::with(
186 &TagKind::custom(tag_names::RELAY),
187 [relay.as_str().to_owned()],
188 ));
189 }
190 for mint in &self.mints {
191 let mut row: Vec<String> = Vec::with_capacity(1 + mint.units.len());
192 row.push(mint.url.as_str().to_owned());
193 for unit in &mint.units {
194 row.push(unit.clone());
195 }
196 tags.push(Tag::with(&TagKind::custom(tag_names::MINT), row));
197 }
198 tags.push(Tag::with(
199 &TagKind::custom(tag_names::PUBKEY),
200 [self.pubkey.to_hex()],
201 ));
202 Ok(tags)
203 }
204
205 pub fn from_event(event: &Event) -> Result<Self, Nip61Error> {
215 if event.kind != KIND_NUTZAP_INFO {
216 return Err(Nip61Error::WrongKind {
217 expected: KIND_NUTZAP_INFO,
218 got: event.kind,
219 });
220 }
221 let mut relays: Vec<RelayUrl> = Vec::new();
222 let mut mints: Vec<NutzapMint> = Vec::new();
223 let mut pubkey: Option<PublicKey> = None;
224 for tag in &event.tags {
225 let values = tag.values();
226 let Some(first) = values.get(1) else {
228 continue;
229 };
230 match tag.name() {
231 tag_names::RELAY => relays.push(RelayUrl::parse(first)?),
232 tag_names::MINT => {
233 let url = Url::parse(first)?;
234 let units: Vec<String> = values.iter().skip(2).cloned().collect();
235 mints.push(NutzapMint { url, units });
236 }
237 tag_names::PUBKEY => pubkey = Some(PublicKey::parse(first)?),
238 _ => {}
239 }
240 }
241 let pubkey = pubkey.ok_or(Nip61Error::MissingPubkey)?;
242 if mints.is_empty() {
243 return Err(Nip61Error::NoMints);
244 }
245 Ok(Self {
246 relays,
247 mints,
248 pubkey,
249 })
250 }
251}
252
253#[derive(Debug, Clone, PartialEq, Eq)]
261pub struct Nutzap {
262 pub comment: String,
264 pub proofs: Vec<CashuProof>,
266 pub unit: Option<String>,
269 pub mint_url: Url,
272 pub target_event: Option<EventId>,
274 pub target_kind: Option<Kind>,
276 pub recipient: PublicKey,
278}
279
280impl Nutzap {
281 #[must_use]
283 pub const fn new(recipient: PublicKey, mint_url: Url, proofs: Vec<CashuProof>) -> Self {
284 Self {
285 comment: String::new(),
286 proofs,
287 unit: None,
288 mint_url,
289 target_event: None,
290 target_kind: None,
291 recipient,
292 }
293 }
294
295 #[must_use]
297 pub fn comment(mut self, comment: impl Into<String>) -> Self {
298 self.comment = comment.into();
299 self
300 }
301
302 #[must_use]
304 pub fn unit(mut self, unit: impl Into<String>) -> Self {
305 self.unit = Some(unit.into());
306 self
307 }
308
309 #[must_use]
311 pub const fn target_event(mut self, event: EventId) -> Self {
312 self.target_event = Some(event);
313 self
314 }
315
316 #[must_use]
318 pub const fn target_kind(mut self, kind: Kind) -> Self {
319 self.target_kind = Some(kind);
320 self
321 }
322
323 #[must_use]
325 pub fn amount(&self) -> u64 {
326 self.proofs.iter().map(|p| p.amount).sum()
327 }
328
329 pub fn to_tags(&self) -> Result<Vec<Tag>, Nip61Error> {
338 if self.proofs.is_empty() {
339 return Err(Nip61Error::NoProofs);
340 }
341 let mut tags: Vec<Tag> = Vec::with_capacity(self.proofs.len() + 5);
342 for proof in &self.proofs {
343 let json = serde_json::to_string(proof)?;
344 tags.push(Tag::with(&TagKind::custom(tag_names::PROOF), [json]));
345 }
346 if let Some(unit) = &self.unit {
347 tags.push(Tag::with(&TagKind::custom(tag_names::UNIT), [unit.clone()]));
348 }
349 tags.push(Tag::with(
350 &TagKind::custom(tag_names::U),
351 [self.mint_url.as_str().to_owned()],
352 ));
353 if let Some(event) = self.target_event {
354 tags.push(Tag::with(
355 &TagKind::single_letter(SingleLetterTag::lowercase(Alphabet::E)),
356 [event.to_hex(), String::new()],
357 ));
358 }
359 if let Some(kind) = self.target_kind {
360 tags.push(Tag::with(
361 &TagKind::custom(tag_names::K),
362 [kind.as_u16().to_string()],
363 ));
364 }
365 tags.push(Tag::with(
366 &TagKind::single_letter(SingleLetterTag::lowercase(Alphabet::P)),
367 [self.recipient.to_hex()],
368 ));
369 Ok(tags)
370 }
371
372 pub fn from_event(event: &Event) -> Result<Self, Nip61Error> {
382 if event.kind != KIND_NUTZAP {
383 return Err(Nip61Error::WrongKind {
384 expected: KIND_NUTZAP,
385 got: event.kind,
386 });
387 }
388 let mut proofs: Vec<CashuProof> = Vec::new();
389 let mut unit: Option<String> = None;
390 let mut mint_url: Option<Url> = None;
391 let mut target_event: Option<EventId> = None;
392 let mut target_kind: Option<Kind> = None;
393 let mut recipient: Option<PublicKey> = None;
394 for tag in &event.tags {
395 let values = tag.values();
396 let Some(first) = values.get(1) else {
397 continue;
398 };
399 match tag.name() {
400 tag_names::PROOF => {
401 let proof: CashuProof = serde_json::from_str(first)?;
402 proofs.push(proof);
403 }
404 tag_names::UNIT => unit = Some(first.clone()),
405 tag_names::U => mint_url = Some(Url::parse(first)?),
406 "e" => target_event = Some(EventId::parse(first)?),
407 tag_names::K => {
408 let raw: u16 = first.parse().map_err(|_| Nip61Error::MalformedKind)?;
409 target_kind = Some(Kind::new(raw));
410 }
411 "p" => recipient = Some(PublicKey::parse(first)?),
412 _ => {}
413 }
414 }
415 if proofs.is_empty() {
416 return Err(Nip61Error::NoProofs);
417 }
418 let mint_url = mint_url.ok_or(Nip61Error::MissingMintUrl)?;
419 let recipient = recipient.ok_or(Nip61Error::MissingRecipient)?;
420 Ok(Self {
421 comment: event.content.clone(),
422 proofs,
423 unit,
424 mint_url,
425 target_event,
426 target_kind,
427 recipient,
428 })
429 }
430}
431
432impl EventBuilder {
433 pub fn nutzap_info(info: &NutzapInfo) -> Result<Self, Nip61Error> {
440 let mut builder = Self::new(KIND_NUTZAP_INFO, "");
441 for tag in info.to_tags()? {
442 builder = builder.tag(tag);
443 }
444 Ok(builder)
445 }
446
447 pub fn nutzap(zap: &Nutzap) -> Result<Self, Nip61Error> {
454 let mut builder = Self::new(KIND_NUTZAP, zap.comment.clone());
455 for tag in zap.to_tags()? {
456 builder = builder.tag(tag);
457 }
458 Ok(builder)
459 }
460}
461
462#[cfg(test)]
463mod tests {
464 use super::*;
465 use crate::Keys;
466
467 fn keys() -> Keys {
468 Keys::parse("0000000000000000000000000000000000000000000000000000000000000003").unwrap()
469 }
470
471 fn other_keys() -> Keys {
472 Keys::parse("0000000000000000000000000000000000000000000000000000000000000005").unwrap()
473 }
474
475 fn mint_url() -> Url {
476 Url::parse("https://stablenut.umint.cash").unwrap()
477 }
478
479 fn relay_url() -> RelayUrl {
480 RelayUrl::parse("wss://relay.example/").unwrap()
481 }
482
483 fn proof(amount: u64) -> CashuProof {
484 CashuProof {
485 id: "000a93d6f8a1d2c4".to_owned(),
486 amount,
487 secret:
488 "[\"P2PK\",{\"nonce\":\"deadbeef\",\"data\":\"02eaee8939e3565e48cc62967e2fde9d8e2a4b3ec0081f29eceff5c64ef10ac1ed\"}]"
489 .to_owned(),
490 c: "02277c66191736eb72fce9d975d08e3191f8f96afb73ab1eec37e4465683066d3f"
491 .to_owned(),
492 }
493 }
494
495 #[test]
496 fn nutzap_info_round_trips_through_event() {
497 let info = NutzapInfo::new(
498 *other_keys().public_key(),
499 vec![NutzapMint::new(mint_url()).unit("sat").unit("usd")],
500 vec![relay_url()],
501 );
502 let event = EventBuilder::nutzap_info(&info)
503 .unwrap()
504 .sign_with_keys(&keys())
505 .unwrap();
506 assert_eq!(event.kind, KIND_NUTZAP_INFO);
507 let recovered = NutzapInfo::from_event(&event).unwrap();
508 assert_eq!(recovered, info);
509 }
510
511 #[test]
512 fn nutzap_info_to_tags_rejects_empty_mints() {
513 let info = NutzapInfo::new(*other_keys().public_key(), Vec::new(), vec![relay_url()]);
514 assert!(matches!(info.to_tags(), Err(Nip61Error::NoMints)));
515 }
516
517 #[test]
518 fn nutzap_info_from_event_requires_pubkey() {
519 let event = EventBuilder::new(KIND_NUTZAP_INFO, "")
520 .tag(Tag::with(
521 &TagKind::custom(tag_names::MINT),
522 [mint_url().as_str().to_owned()],
523 ))
524 .sign_with_keys(&keys())
525 .unwrap();
526 assert!(matches!(
527 NutzapInfo::from_event(&event),
528 Err(Nip61Error::MissingPubkey),
529 ));
530 }
531
532 #[test]
533 fn nutzap_info_from_event_requires_mint() {
534 let event = EventBuilder::new(KIND_NUTZAP_INFO, "")
535 .tag(Tag::with(
536 &TagKind::custom(tag_names::PUBKEY),
537 [other_keys().public_key().to_hex()],
538 ))
539 .sign_with_keys(&keys())
540 .unwrap();
541 assert!(matches!(
542 NutzapInfo::from_event(&event),
543 Err(Nip61Error::NoMints),
544 ));
545 }
546
547 #[test]
548 fn nutzap_info_from_event_rejects_wrong_kind() {
549 let event = EventBuilder::text_note("not info")
550 .sign_with_keys(&keys())
551 .unwrap();
552 assert!(matches!(
553 NutzapInfo::from_event(&event),
554 Err(Nip61Error::WrongKind { .. }),
555 ));
556 }
557
558 #[test]
559 fn nutzap_round_trips_through_event() {
560 let zap = Nutzap::new(
561 *other_keys().public_key(),
562 mint_url(),
563 vec![proof(1), proof(2)],
564 )
565 .comment("Thanks for this great idea.")
566 .unit("sat")
567 .target_event(EventId::from_byte_array([0xab; 32]))
568 .target_kind(Kind::TEXT_NOTE);
569 let event = EventBuilder::nutzap(&zap)
570 .unwrap()
571 .sign_with_keys(&keys())
572 .unwrap();
573 assert_eq!(event.kind, KIND_NUTZAP);
574 assert_eq!(event.content, "Thanks for this great idea.");
575 let recovered = Nutzap::from_event(&event).unwrap();
576 assert_eq!(recovered, zap);
577 assert_eq!(recovered.amount(), 3);
578 }
579
580 #[test]
581 fn nutzap_to_tags_rejects_empty_proofs() {
582 let zap = Nutzap::new(*other_keys().public_key(), mint_url(), Vec::new());
583 assert!(matches!(zap.to_tags(), Err(Nip61Error::NoProofs)));
584 }
585
586 #[test]
587 fn nutzap_from_event_requires_proofs() {
588 let event = EventBuilder::new(KIND_NUTZAP, "")
589 .tag(Tag::with(
590 &TagKind::custom(tag_names::U),
591 [mint_url().as_str().to_owned()],
592 ))
593 .tag(Tag::with(
594 &TagKind::single_letter(SingleLetterTag::lowercase(Alphabet::P)),
595 [other_keys().public_key().to_hex()],
596 ))
597 .sign_with_keys(&keys())
598 .unwrap();
599 assert!(matches!(
600 Nutzap::from_event(&event),
601 Err(Nip61Error::NoProofs),
602 ));
603 }
604
605 #[test]
606 fn nutzap_from_event_requires_recipient() {
607 let event = EventBuilder::new(KIND_NUTZAP, "")
608 .tag(Tag::with(
609 &TagKind::custom(tag_names::PROOF),
610 [serde_json::to_string(&proof(1)).unwrap()],
611 ))
612 .tag(Tag::with(
613 &TagKind::custom(tag_names::U),
614 [mint_url().as_str().to_owned()],
615 ))
616 .sign_with_keys(&keys())
617 .unwrap();
618 assert!(matches!(
619 Nutzap::from_event(&event),
620 Err(Nip61Error::MissingRecipient),
621 ));
622 }
623
624 #[test]
625 fn nutzap_from_event_requires_mint_url() {
626 let event = EventBuilder::new(KIND_NUTZAP, "")
627 .tag(Tag::with(
628 &TagKind::custom(tag_names::PROOF),
629 [serde_json::to_string(&proof(1)).unwrap()],
630 ))
631 .tag(Tag::with(
632 &TagKind::single_letter(SingleLetterTag::lowercase(Alphabet::P)),
633 [other_keys().public_key().to_hex()],
634 ))
635 .sign_with_keys(&keys())
636 .unwrap();
637 assert!(matches!(
638 Nutzap::from_event(&event),
639 Err(Nip61Error::MissingMintUrl),
640 ));
641 }
642
643 #[test]
644 fn nutzap_from_event_rejects_wrong_kind() {
645 let event = EventBuilder::text_note("not a nutzap")
646 .sign_with_keys(&keys())
647 .unwrap();
648 assert!(matches!(
649 Nutzap::from_event(&event),
650 Err(Nip61Error::WrongKind { .. }),
651 ));
652 }
653}