use serde::{Deserialize, Serialize};
use thiserror::Error;
use crate::event::{
Alphabet, Event, EventBuilder, EventBuilderError, EventId, EventIdError, Kind, SingleLetterTag,
Tag, TagError, TagKind, Tags,
};
use crate::key::{Keys, SecretKey, SecretKeyError};
use crate::nips::nip40::EXPIRATION_TAG;
use crate::nips::nip44;
use crate::types::{Timestamp, Url, UrlError};
pub const KIND_CASHU_QUOTE: Kind = Kind::CASHU_QUOTE;
pub const KIND_CASHU_TOKEN: Kind = Kind::CASHU_TOKEN;
pub const KIND_CASHU_HISTORY: Kind = Kind::CASHU_HISTORY;
pub const KIND_CASHU_WALLET: Kind = Kind::CASHU_WALLET;
mod tag_names {
pub(super) const PRIVKEY: &str = "privkey";
pub(super) const MINT: &str = "mint";
pub(super) const UNIT: &str = "unit";
pub(super) const AMOUNT: &str = "amount";
pub(super) const DIRECTION: &str = "direction";
}
mod history_markers {
pub(super) const CREATED: &str = "created";
pub(super) const DESTROYED: &str = "destroyed";
pub(super) const REDEEMED: &str = "redeemed";
}
#[derive(Debug, Error)]
#[non_exhaustive]
pub enum Nip60Error {
#[error("expected kind {expected}, got {got}")]
WrongKind {
expected: Kind,
got: Kind,
},
#[error("NIP-60 wallet must declare at least one mint")]
NoMints,
#[error("NIP-60 history entry missing `direction` row")]
MissingDirection,
#[error("NIP-60 history entry missing `amount` row")]
MissingAmount,
#[error("NIP-60 history `e` reference missing event id")]
MissingHistoryReference,
#[error("NIP-60 quote event missing `mint` tag")]
MissingMint,
#[error("NIP-60 quote event missing NIP-40 `expiration` tag")]
MissingExpiration,
#[error("NIP-60 quote event `expiration` value is not a unix timestamp")]
MalformedExpiration,
#[error(transparent)]
Json(#[from] serde_json::Error),
#[error(transparent)]
Nip44(#[from] nip44::Nip44Error),
#[error(transparent)]
Url(#[from] UrlError),
#[error(transparent)]
SecretKey(#[from] SecretKeyError),
#[error(transparent)]
EventId(#[from] EventIdError),
#[error(transparent)]
Tag(#[from] TagError),
#[error(transparent)]
Builder(#[from] EventBuilderError),
}
#[derive(Debug, Clone)]
pub struct WalletInfo {
pub mints: Vec<Url>,
pub privkey: Option<SecretKey>,
}
impl WalletInfo {
#[must_use]
pub const fn new(mints: Vec<Url>) -> Self {
Self {
mints,
privkey: None,
}
}
#[must_use]
pub fn with_privkey(mut self, privkey: SecretKey) -> Self {
self.privkey = Some(privkey);
self
}
fn to_inner_tags(&self) -> Vec<Vec<String>> {
let mut out: Vec<Vec<String>> = Vec::with_capacity(self.mints.len() + 1);
if let Some(pk) = &self.privkey {
out.push(vec![tag_names::PRIVKEY.to_owned(), pk.to_hex()]);
}
for mint in &self.mints {
out.push(vec![tag_names::MINT.to_owned(), mint.as_str().to_owned()]);
}
out
}
pub fn encrypt(&self, owner: &Keys) -> Result<String, Nip60Error> {
if self.mints.is_empty() {
return Err(Nip60Error::NoMints);
}
let json = serde_json::to_string(&self.to_inner_tags())?;
Ok(nip44::encrypt(
owner.secret_key(),
owner.public_key(),
&json,
)?)
}
pub fn decrypt(payload: &str, owner: &Keys) -> Result<Self, Nip60Error> {
let json = nip44::decrypt(owner.secret_key(), owner.public_key(), payload)?;
let raw: Vec<Vec<String>> = serde_json::from_str(&json)?;
let mut mints: Vec<Url> = Vec::new();
let mut privkey: Option<SecretKey> = None;
for row in raw {
let Some((head, rest)) = row.split_first() else {
continue;
};
let Some(value) = rest.first() else {
continue;
};
match head.as_str() {
tag_names::MINT => mints.push(Url::parse(value)?),
tag_names::PRIVKEY => privkey = Some(SecretKey::parse(value)?),
_ => {}
}
}
if mints.is_empty() {
return Err(Nip60Error::NoMints);
}
Ok(Self { mints, privkey })
}
pub fn from_event(event: &Event, owner: &Keys) -> Result<Self, Nip60Error> {
if event.kind != KIND_CASHU_WALLET {
return Err(Nip60Error::WrongKind {
expected: KIND_CASHU_WALLET,
got: event.kind,
});
}
Self::decrypt(&event.content, owner)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct CashuProof {
pub id: String,
pub amount: u64,
pub secret: String,
#[serde(rename = "C")]
pub c: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct TokenContent {
pub mint: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub unit: Option<String>,
pub proofs: Vec<CashuProof>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub del: Vec<String>,
}
impl TokenContent {
#[must_use]
pub fn new(mint: impl Into<String>, proofs: Vec<CashuProof>) -> Self {
Self {
mint: mint.into(),
unit: None,
proofs,
del: Vec::new(),
}
}
#[must_use]
pub fn unit(mut self, unit: impl Into<String>) -> Self {
self.unit = Some(unit.into());
self
}
#[must_use]
pub fn del(mut self, del: impl IntoIterator<Item = impl Into<String>>) -> Self {
self.del = del.into_iter().map(Into::into).collect();
self
}
#[must_use]
pub fn amount(&self) -> u64 {
self.proofs.iter().map(|p| p.amount).sum()
}
pub fn encrypt(&self, owner: &Keys) -> Result<String, Nip60Error> {
let json = serde_json::to_string(self)?;
Ok(nip44::encrypt(
owner.secret_key(),
owner.public_key(),
&json,
)?)
}
pub fn decrypt(payload: &str, owner: &Keys) -> Result<Self, Nip60Error> {
let json = nip44::decrypt(owner.secret_key(), owner.public_key(), payload)?;
Ok(serde_json::from_str(&json)?)
}
pub fn from_event(event: &Event, owner: &Keys) -> Result<Self, Nip60Error> {
if event.kind != KIND_CASHU_TOKEN {
return Err(Nip60Error::WrongKind {
expected: KIND_CASHU_TOKEN,
got: event.kind,
});
}
Self::decrypt(&event.content, owner)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum Direction {
In,
Out,
}
impl Direction {
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::In => "in",
Self::Out => "out",
}
}
#[must_use]
pub const fn from_wire(s: &str) -> Option<Self> {
match s.as_bytes() {
b"in" => Some(Self::In),
b"out" => Some(Self::Out),
_ => None,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct HistoryEntry {
pub direction: Direction,
pub amount: u64,
pub unit: Option<String>,
pub created: Vec<EventId>,
pub destroyed: Vec<EventId>,
pub redeemed: Vec<EventId>,
}
impl HistoryEntry {
#[must_use]
pub const fn new(direction: Direction, amount: u64) -> Self {
Self {
direction,
amount,
unit: None,
created: Vec::new(),
destroyed: Vec::new(),
redeemed: Vec::new(),
}
}
#[must_use]
pub fn unit(mut self, unit: impl Into<String>) -> Self {
self.unit = Some(unit.into());
self
}
#[must_use]
pub fn created(mut self, id: EventId) -> Self {
self.created.push(id);
self
}
#[must_use]
pub fn destroyed(mut self, id: EventId) -> Self {
self.destroyed.push(id);
self
}
#[must_use]
pub fn redeemed(mut self, id: EventId) -> Self {
self.redeemed.push(id);
self
}
fn encrypted_rows(&self) -> Vec<Vec<String>> {
let mut rows: Vec<Vec<String>> =
Vec::with_capacity(3 + self.created.len() + self.destroyed.len());
rows.push(vec![
tag_names::DIRECTION.to_owned(),
self.direction.as_str().to_owned(),
]);
rows.push(vec![tag_names::AMOUNT.to_owned(), self.amount.to_string()]);
if let Some(unit) = &self.unit {
rows.push(vec![tag_names::UNIT.to_owned(), unit.clone()]);
}
for id in &self.created {
rows.push(vec![
"e".to_owned(),
id.to_hex(),
String::new(),
history_markers::CREATED.to_owned(),
]);
}
for id in &self.destroyed {
rows.push(vec![
"e".to_owned(),
id.to_hex(),
String::new(),
history_markers::DESTROYED.to_owned(),
]);
}
rows
}
#[must_use]
pub fn public_tags(&self) -> Vec<Tag> {
let kind = TagKind::single_letter(SingleLetterTag::lowercase(Alphabet::E));
let mut out: Vec<Tag> = Vec::with_capacity(self.redeemed.len());
for id in &self.redeemed {
out.push(Tag::with(
&kind,
[
id.to_hex(),
String::new(),
history_markers::REDEEMED.to_owned(),
],
));
}
out
}
pub fn encrypt(&self, owner: &Keys) -> Result<String, Nip60Error> {
let json = serde_json::to_string(&self.encrypted_rows())?;
Ok(nip44::encrypt(
owner.secret_key(),
owner.public_key(),
&json,
)?)
}
pub fn decrypt(
encrypted_payload: &str,
public_tags: &Tags,
owner: &Keys,
) -> Result<Self, Nip60Error> {
let json = nip44::decrypt(owner.secret_key(), owner.public_key(), encrypted_payload)?;
let rows: Vec<Vec<String>> = serde_json::from_str(&json)?;
let mut direction: Option<Direction> = None;
let mut amount: Option<u64> = None;
let mut unit: Option<String> = None;
let mut created: Vec<EventId> = Vec::new();
let mut destroyed: Vec<EventId> = Vec::new();
for row in rows {
ingest_encrypted_row(
&row,
&mut direction,
&mut amount,
&mut unit,
&mut created,
&mut destroyed,
)?;
}
let direction = direction.ok_or(Nip60Error::MissingDirection)?;
let amount = amount.ok_or(Nip60Error::MissingAmount)?;
let mut redeemed: Vec<EventId> = Vec::new();
for tag in public_tags {
if tag.name() != "e" {
continue;
}
let values = tag.values();
let marker = values.get(3).map(String::as_str).unwrap_or_default();
if marker != history_markers::REDEEMED {
continue;
}
let id_hex = values.get(1).ok_or(Nip60Error::MissingHistoryReference)?;
redeemed.push(EventId::parse(id_hex)?);
}
Ok(Self {
direction,
amount,
unit,
created,
destroyed,
redeemed,
})
}
pub fn from_event(event: &Event, owner: &Keys) -> Result<Self, Nip60Error> {
if event.kind != KIND_CASHU_HISTORY {
return Err(Nip60Error::WrongKind {
expected: KIND_CASHU_HISTORY,
got: event.kind,
});
}
Self::decrypt(&event.content, &event.tags, owner)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct QuoteState {
pub mint: Url,
pub quote_id: String,
pub expiration: Timestamp,
}
impl QuoteState {
#[must_use]
pub fn new(mint: Url, quote_id: impl Into<String>, expiration: Timestamp) -> Self {
Self {
mint,
quote_id: quote_id.into(),
expiration,
}
}
pub fn encrypt(&self, owner: &Keys) -> Result<String, Nip60Error> {
Ok(nip44::encrypt(
owner.secret_key(),
owner.public_key(),
&self.quote_id,
)?)
}
#[must_use]
pub fn to_tags(&self) -> Vec<Tag> {
vec![
Tag::with(
&TagKind::from_wire(EXPIRATION_TAG),
[self.expiration.as_secs().to_string()],
),
Tag::with(
&TagKind::custom(tag_names::MINT),
[self.mint.as_str().to_owned()],
),
]
}
pub fn from_event(event: &Event, owner: &Keys) -> Result<Self, Nip60Error> {
if event.kind != KIND_CASHU_QUOTE {
return Err(Nip60Error::WrongKind {
expected: KIND_CASHU_QUOTE,
got: event.kind,
});
}
let mut mint: Option<Url> = None;
let mut expiration: Option<Timestamp> = None;
for tag in &event.tags {
let Some(value) = tag.values().get(1) else {
continue;
};
match tag.name() {
tag_names::MINT => mint = Some(Url::parse(value)?),
EXPIRATION_TAG => {
let secs: u64 = value.parse().map_err(|_| Nip60Error::MalformedExpiration)?;
expiration = Some(Timestamp::from_secs(secs));
}
_ => {}
}
}
let mint = mint.ok_or(Nip60Error::MissingMint)?;
let expiration = expiration.ok_or(Nip60Error::MissingExpiration)?;
let quote_id = nip44::decrypt(owner.secret_key(), owner.public_key(), &event.content)?;
Ok(Self {
mint,
quote_id,
expiration,
})
}
}
impl EventBuilder {
pub fn cashu_wallet(info: &WalletInfo, owner: &Keys) -> Result<Self, Nip60Error> {
let payload = info.encrypt(owner)?;
Ok(Self::new(KIND_CASHU_WALLET, payload))
}
pub fn cashu_token(token: &TokenContent, owner: &Keys) -> Result<Self, Nip60Error> {
let payload = token.encrypt(owner)?;
Ok(Self::new(KIND_CASHU_TOKEN, payload))
}
pub fn cashu_history(entry: &HistoryEntry, owner: &Keys) -> Result<Self, Nip60Error> {
let payload = entry.encrypt(owner)?;
let mut builder = Self::new(KIND_CASHU_HISTORY, payload);
for tag in entry.public_tags() {
builder = builder.tag(tag);
}
Ok(builder)
}
pub fn cashu_quote(quote: &QuoteState, owner: &Keys) -> Result<Self, Nip60Error> {
let payload = quote.encrypt(owner)?;
let mut builder = Self::new(KIND_CASHU_QUOTE, payload);
for tag in quote.to_tags() {
builder = builder.tag(tag);
}
Ok(builder)
}
}
fn ingest_encrypted_row(
row: &[String],
direction: &mut Option<Direction>,
amount: &mut Option<u64>,
unit: &mut Option<String>,
created: &mut Vec<EventId>,
destroyed: &mut Vec<EventId>,
) -> Result<(), Nip60Error> {
let Some((head, rest)) = row.split_first() else {
return Ok(());
};
match head.as_str() {
tag_names::DIRECTION => {
if let Some(v) = rest.first() {
*direction = Direction::from_wire(v);
}
}
tag_names::AMOUNT => {
if let Some(v) = rest.first() {
*amount = v.parse().ok();
}
}
tag_names::UNIT => {
*unit = rest.first().cloned();
}
"e" => {
let id_hex = rest.first().ok_or(Nip60Error::MissingHistoryReference)?;
let id = EventId::parse(id_hex)?;
let marker = rest.get(2).map(String::as_str).unwrap_or_default();
match marker {
history_markers::CREATED => created.push(id),
history_markers::DESTROYED => destroyed.push(id),
_ => {}
}
}
_ => {}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
fn keys() -> Keys {
Keys::parse("0000000000000000000000000000000000000000000000000000000000000003").unwrap()
}
fn other_keys() -> Keys {
Keys::parse("0000000000000000000000000000000000000000000000000000000000000005").unwrap()
}
fn mint() -> Url {
Url::parse("https://stablenut.umint.cash").unwrap()
}
fn second_mint() -> Url {
Url::parse("https://mint.example/").unwrap()
}
fn fixture_proof(amount: u64, secret: &str) -> CashuProof {
CashuProof {
id: "005c2502034d4f12".to_owned(),
amount,
secret: secret.to_owned(),
c: "0241d98a8197ef238a192d47edf191a9de78b657308937b4f7dd0aa53beae72c46".to_owned(),
}
}
#[test]
fn wallet_round_trips_through_encrypt_decrypt() {
let owner = keys();
let info = WalletInfo::new(vec![mint(), second_mint()])
.with_privkey(other_keys().secret_key().clone());
let payload = info.encrypt(&owner).unwrap();
let recovered = WalletInfo::decrypt(&payload, &owner).unwrap();
assert_eq!(recovered.mints, info.mints);
assert_eq!(
recovered.privkey.as_ref().map(SecretKey::to_hex),
info.privkey.as_ref().map(SecretKey::to_hex),
);
}
#[test]
fn wallet_encrypt_rejects_empty_mints() {
let owner = keys();
let info = WalletInfo::new(Vec::new());
assert!(matches!(info.encrypt(&owner), Err(Nip60Error::NoMints)));
}
#[test]
fn wallet_from_event_rejects_wrong_kind() {
let owner = keys();
let info = WalletInfo::new(vec![mint()]);
let payload = info.encrypt(&owner).unwrap();
let event = EventBuilder::new(Kind::TEXT_NOTE, payload)
.sign_with_keys(&owner)
.unwrap();
assert!(matches!(
WalletInfo::from_event(&event, &owner),
Err(Nip60Error::WrongKind { .. })
));
}
#[test]
fn wallet_event_round_trips() {
let owner = keys();
let info = WalletInfo::new(vec![mint()]).with_privkey(other_keys().secret_key().clone());
let event = EventBuilder::cashu_wallet(&info, &owner)
.unwrap()
.sign_with_keys(&owner)
.unwrap();
assert_eq!(event.kind, KIND_CASHU_WALLET);
let recovered = WalletInfo::from_event(&event, &owner).unwrap();
assert_eq!(recovered.mints, info.mints);
}
#[test]
fn token_round_trips_through_encrypt_decrypt() {
let owner = keys();
let token = TokenContent::new(
mint().as_str(),
vec![
fixture_proof(1, "z+zyxAVLRqN9lEjxuNPSyRJzEstbl69Jc1vtimvtkPg="),
fixture_proof(2, "z+zyxAVLRqN9lEjxuNPSyRJzEstbl69Jc1vtimvtkPa="),
],
)
.unit("sat")
.del(["aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"]);
let payload = token.encrypt(&owner).unwrap();
let recovered = TokenContent::decrypt(&payload, &owner).unwrap();
assert_eq!(recovered, token);
assert_eq!(recovered.amount(), 3);
}
#[test]
fn token_proof_serializes_uppercase_c() {
let proof = fixture_proof(8, "secret");
let json = serde_json::to_string(&proof).unwrap();
assert!(json.contains("\"C\":"), "wire form must use uppercase C");
assert!(!json.contains("\"c\":"), "lowercase c MUST NOT appear");
}
#[test]
fn token_event_round_trips_via_event_builder() {
let owner = keys();
let token = TokenContent::new(mint().as_str(), vec![fixture_proof(4, "abc")]);
let event = EventBuilder::cashu_token(&token, &owner)
.unwrap()
.sign_with_keys(&owner)
.unwrap();
assert_eq!(event.kind, KIND_CASHU_TOKEN);
let recovered = TokenContent::from_event(&event, &owner).unwrap();
assert_eq!(recovered, token);
}
#[test]
fn token_from_event_rejects_wrong_kind() {
let owner = keys();
let token = TokenContent::new(mint().as_str(), vec![fixture_proof(1, "x")]);
let payload = token.encrypt(&owner).unwrap();
let event = EventBuilder::new(Kind::TEXT_NOTE, payload)
.sign_with_keys(&owner)
.unwrap();
assert!(matches!(
TokenContent::from_event(&event, &owner),
Err(Nip60Error::WrongKind { .. })
));
}
#[test]
fn direction_round_trips_through_wire_form() {
assert_eq!(Direction::In.as_str(), "in");
assert_eq!(Direction::Out.as_str(), "out");
assert_eq!(Direction::from_wire("in"), Some(Direction::In));
assert_eq!(Direction::from_wire("out"), Some(Direction::Out));
assert_eq!(Direction::from_wire("INVALID"), None);
}
#[test]
fn history_round_trips_with_public_redeemed_tag() {
let owner = keys();
let created_id = EventId::from_byte_array([0xaa; 32]);
let destroyed_id = EventId::from_byte_array([0xbb; 32]);
let redeemed_id = EventId::from_byte_array([0xcc; 32]);
let entry = HistoryEntry::new(Direction::Out, 4)
.unit("sat")
.created(created_id)
.destroyed(destroyed_id)
.redeemed(redeemed_id);
let event = EventBuilder::cashu_history(&entry, &owner)
.unwrap()
.sign_with_keys(&owner)
.unwrap();
assert_eq!(event.kind, KIND_CASHU_HISTORY);
let public_redeemed_count = event
.tags
.iter()
.filter(|t| t.name() == "e")
.filter(|t| t.values().get(3).map(String::as_str) == Some("redeemed"))
.count();
assert_eq!(public_redeemed_count, 1);
let recovered = HistoryEntry::from_event(&event, &owner).unwrap();
assert_eq!(recovered, entry);
}
#[test]
fn history_from_event_rejects_wrong_kind() {
let owner = keys();
let entry = HistoryEntry::new(Direction::In, 1);
let payload = entry.encrypt(&owner).unwrap();
let event = EventBuilder::new(Kind::TEXT_NOTE, payload)
.sign_with_keys(&owner)
.unwrap();
assert!(matches!(
HistoryEntry::from_event(&event, &owner),
Err(Nip60Error::WrongKind { .. })
));
}
#[test]
fn quote_round_trips_through_event_builder() {
let owner = keys();
let quote = QuoteState::new(mint(), "abc-quote-id", Timestamp::from_secs(1_700_000_000));
let event = EventBuilder::cashu_quote("e, &owner)
.unwrap()
.sign_with_keys(&owner)
.unwrap();
assert_eq!(event.kind, KIND_CASHU_QUOTE);
let mint_tag = event.tags.iter().any(|t| t.name() == "mint");
let expiration_tag = event.tags.iter().any(|t| t.name() == "expiration");
assert!(mint_tag);
assert!(expiration_tag);
let recovered = QuoteState::from_event(&event, &owner).unwrap();
assert_eq!(recovered, quote);
}
#[test]
fn quote_from_event_requires_mint_and_expiration() {
let owner = keys();
let payload = nip44::encrypt(owner.secret_key(), owner.public_key(), "quote").unwrap();
let no_tags = EventBuilder::new(KIND_CASHU_QUOTE, payload.clone())
.sign_with_keys(&owner)
.unwrap();
assert!(matches!(
QuoteState::from_event(&no_tags, &owner),
Err(Nip60Error::MissingMint),
));
let only_mint = EventBuilder::new(KIND_CASHU_QUOTE, payload)
.tag(Tag::with(
&TagKind::custom(tag_names::MINT),
[mint().as_str().to_owned()],
))
.sign_with_keys(&owner)
.unwrap();
assert!(matches!(
QuoteState::from_event(&only_mint, &owner),
Err(Nip60Error::MissingExpiration),
));
}
}