1use 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
55pub const KIND_CASHU_QUOTE: Kind = Kind::CASHU_QUOTE;
57pub const KIND_CASHU_TOKEN: Kind = Kind::CASHU_TOKEN;
59pub const KIND_CASHU_HISTORY: Kind = Kind::CASHU_HISTORY;
61pub 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#[derive(Debug, Error)]
80#[non_exhaustive]
81pub enum Nip60Error {
82 #[error("expected kind {expected}, got {got}")]
84 WrongKind {
85 expected: Kind,
87 got: Kind,
89 },
90 #[error("NIP-60 wallet must declare at least one mint")]
92 NoMints,
93 #[error("NIP-60 history entry missing `direction` row")]
95 MissingDirection,
96 #[error("NIP-60 history entry missing `amount` row")]
98 MissingAmount,
99 #[error("NIP-60 history `e` reference missing event id")]
101 MissingHistoryReference,
102 #[error("NIP-60 quote event missing `mint` tag")]
104 MissingMint,
105 #[error("NIP-60 quote event missing NIP-40 `expiration` tag")]
107 MissingExpiration,
108 #[error("NIP-60 quote event `expiration` value is not a unix timestamp")]
110 MalformedExpiration,
111 #[error(transparent)]
113 Json(#[from] serde_json::Error),
114 #[error(transparent)]
116 Nip44(#[from] nip44::Nip44Error),
117 #[error(transparent)]
119 Url(#[from] UrlError),
120 #[error(transparent)]
122 SecretKey(#[from] SecretKeyError),
123 #[error(transparent)]
125 EventId(#[from] EventIdError),
126 #[error(transparent)]
128 Tag(#[from] TagError),
129 #[error(transparent)]
131 Builder(#[from] EventBuilderError),
132}
133
134#[derive(Debug, Clone)]
141pub struct WalletInfo {
142 pub mints: Vec<Url>,
144 pub privkey: Option<SecretKey>,
146}
147
148impl WalletInfo {
149 #[must_use]
151 pub const fn new(mints: Vec<Url>) -> Self {
152 Self {
153 mints,
154 privkey: None,
155 }
156 }
157
158 #[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 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 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 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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
250pub struct CashuProof {
251 pub id: String,
253 pub amount: u64,
255 pub secret: String,
257 #[serde(rename = "C")]
259 pub c: String,
260}
261
262#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
264pub struct TokenContent {
265 pub mint: String,
267 #[serde(default, skip_serializing_if = "Option::is_none")]
270 pub unit: Option<String>,
271 pub proofs: Vec<CashuProof>,
273 #[serde(default, skip_serializing_if = "Vec::is_empty")]
276 pub del: Vec<String>,
277}
278
279impl TokenContent {
280 #[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 #[must_use]
293 pub fn unit(mut self, unit: impl Into<String>) -> Self {
294 self.unit = Some(unit.into());
295 self
296 }
297
298 #[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 #[must_use]
307 pub fn amount(&self) -> u64 {
308 self.proofs.iter().map(|p| p.amount).sum()
309 }
310
311 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 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 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
355#[non_exhaustive]
356pub enum Direction {
357 In,
359 Out,
361}
362
363impl Direction {
364 #[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 #[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#[derive(Debug, Clone, PartialEq, Eq)]
392pub struct HistoryEntry {
393 pub direction: Direction,
395 pub amount: u64,
397 pub unit: Option<String>,
399 pub created: Vec<EventId>,
401 pub destroyed: Vec<EventId>,
403 pub redeemed: Vec<EventId>,
405}
406
407impl HistoryEntry {
408 #[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 #[must_use]
423 pub fn unit(mut self, unit: impl Into<String>) -> Self {
424 self.unit = Some(unit.into());
425 self
426 }
427
428 #[must_use]
430 pub fn created(mut self, id: EventId) -> Self {
431 self.created.push(id);
432 self
433 }
434
435 #[must_use]
437 pub fn destroyed(mut self, id: EventId) -> Self {
438 self.destroyed.push(id);
439 self
440 }
441
442 #[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 #[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 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 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 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 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#[derive(Debug, Clone, PartialEq, Eq)]
599pub struct QuoteState {
600 pub mint: Url,
602 pub quote_id: String,
604 pub expiration: Timestamp,
606}
607
608impl QuoteState {
609 #[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 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 #[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 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 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 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 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 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 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
758fn 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 _ => {}
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 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("e, &owner)
1000 .unwrap()
1001 .sign_with_keys(&owner)
1002 .unwrap();
1003 assert_eq!(event.kind, KIND_CASHU_QUOTE);
1004
1005 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}