use alloc::string::{String, ToString};
use alloc::vec::Vec;
use core::fmt;
use core::str::FromStr;
use serde::{Deserialize, Serialize};
use super::nip44;
use crate::event::{self, tag, Event, EventId, TagKind};
#[cfg(feature = "std")]
use crate::event::{EventBuilder, Kind, Tag};
use crate::key::{PublicKey, SecretKey};
use crate::types::time::Timestamp;
use crate::types::url::{ParseError, Url};
const E_TAG_STR: &str = "e";
const PRIVKEY: &str = "privkey";
const MINT: &str = "mint";
const DIRECTION: &str = "direction";
const AMOUNT: &str = "amount";
const EVENT_MARKER_CREATED: &str = "created";
const EVENT_MARKER_DESTROYED: &str = "destroyed";
const EVENT_MARKER_REDEEMED: &str = "redeemed";
#[derive(Debug)]
pub enum Error {
Nip44(nip44::Error),
Json(serde_json::Error),
Event(event::Error),
Tag(tag::Error),
Url(ParseError),
InvalidDirection,
FoundMultiplePrivKeys,
MissingField(String),
InvalidAmount,
MissingMintTag,
InvalidMintUrl,
}
#[cfg(feature = "std")]
impl std::error::Error for Error {}
impl fmt::Display for Error {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Nip44(e) => e.fmt(f),
Self::Json(e) => e.fmt(f),
Self::Event(e) => e.fmt(f),
Self::Tag(e) => e.fmt(f),
Self::Url(e) => e.fmt(f),
Self::InvalidDirection => f.write_str("Invalid direction"),
Self::FoundMultiplePrivKeys => f.write_str("Found multiple private keys"),
Self::MissingField(field) => write!(f, "Missing required field: {field}"),
Self::InvalidAmount => f.write_str("Invalid amount"),
Self::MissingMintTag => f.write_str("Missing mint tag"),
Self::InvalidMintUrl => f.write_str("Invalid mint URL"),
}
}
}
impl From<nip44::Error> for Error {
fn from(e: nip44::Error) -> Self {
Self::Nip44(e)
}
}
impl From<serde_json::Error> for Error {
fn from(e: serde_json::Error) -> Self {
Self::Json(e)
}
}
impl From<event::Error> for Error {
fn from(e: event::Error) -> Self {
Self::Event(e)
}
}
impl From<tag::Error> for Error {
fn from(e: tag::Error) -> Self {
Self::Tag(e)
}
}
impl From<ParseError> for Error {
fn from(e: ParseError) -> Self {
Self::Url(e)
}
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
pub struct CashuProof {
pub id: String,
pub amount: u64,
pub secret: String,
pub c: String,
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct WalletEvent {
pub privkey: String,
pub mints: Vec<Url>,
}
impl WalletEvent {
pub fn new<S>(privkey: S, mints: Vec<Url>) -> Self
where
S: Into<String>,
{
Self {
privkey: privkey.into(),
mints,
}
}
pub fn from_wallet_event(
secret_key: &SecretKey,
public_key: &PublicKey,
event: &Event,
) -> Result<Self, Error> {
let decrypted: String = nip44::decrypt(secret_key, public_key, &event.content)?;
let wallet_data: Vec<Vec<String>> = serde_json::from_str(&decrypted)?;
let mut privkey: String = String::new();
let mut mints: Vec<Url> = Vec::new();
for item in wallet_data.into_iter() {
let mut iter = item.into_iter();
if let (Some(key), Some(value)) = (iter.next(), iter.next()) {
match key.as_str() {
PRIVKEY => {
if privkey.is_empty() {
privkey = value
} else {
return Err(Error::FoundMultiplePrivKeys);
}
}
MINT => {
if let Ok(mint_url) = Url::parse(&value) {
mints.push(mint_url);
}
}
_ => {}
}
}
}
if privkey.is_empty() {
return Err(Error::MissingField(PRIVKEY.to_string()));
}
if mints.is_empty() {
return Err(Error::MissingField(MINT.to_string()));
}
Ok(Self { privkey, mints })
}
#[cfg(feature = "std")]
fn to_encrypted_content(
&self,
secret_key: &SecretKey,
public_key: &PublicKey,
) -> Result<String, Error> {
let mut wallet_data: Vec<Vec<&str>> = vec![vec![PRIVKEY, &self.privkey]];
for mint in self.mints.iter() {
wallet_data.push(vec![MINT, mint.as_str()]);
}
let json: String = serde_json::to_string(&wallet_data)?;
Ok(nip44::encrypt(
secret_key,
public_key,
json,
nip44::Version::V2,
)?)
}
#[cfg(feature = "std")]
pub fn to_event_builder(
&self,
secret_key: &SecretKey,
public_key: &PublicKey,
) -> Result<EventBuilder, Error> {
let content: String = self.to_encrypted_content(secret_key, public_key)?;
Ok(EventBuilder::new(Kind::CashuWallet, content))
}
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
pub struct TokenEvent {
pub mint: Url,
pub proofs: Vec<CashuProof>,
pub del: Vec<String>,
}
impl TokenEvent {
pub fn new(mint: Url, proofs: Vec<CashuProof>) -> Self {
Self {
mint,
proofs,
del: Vec::new(),
}
}
pub fn from_token_event(
secret_key: &SecretKey,
public_key: &PublicKey,
event: &Event,
) -> Result<Self, Error> {
let decrypted: String = nip44::decrypt(secret_key, public_key, &event.content)?;
Ok(serde_json::from_str(&decrypted)?)
}
pub fn destroyed(mut self, event_id: String) -> Self {
self.del.push(event_id);
self
}
#[cfg(feature = "std")]
fn to_encrypted_content(
&self,
secret_key: &SecretKey,
public_key: &PublicKey,
) -> Result<String, Error> {
let json: String = serde_json::to_string(self)?;
Ok(nip44::encrypt(
secret_key,
public_key,
json,
nip44::Version::V2,
)?)
}
#[cfg(feature = "std")]
pub fn to_event_builder(
&self,
secret_key: &SecretKey,
public_key: &PublicKey,
) -> Result<EventBuilder, Error> {
let content: String = self.to_encrypted_content(secret_key, public_key)?;
Ok(EventBuilder::new(Kind::CashuWalletUnspentProof, content))
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub enum TransactionDirection {
In,
Out,
}
impl fmt::Display for TransactionDirection {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.as_str())
}
}
impl TransactionDirection {
pub fn as_str(&self) -> &str {
match self {
Self::In => "in",
Self::Out => "out",
}
}
}
impl FromStr for TransactionDirection {
type Err = Error;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s {
"in" => Ok(Self::In),
"out" => Ok(Self::Out),
_ => Err(Error::InvalidDirection),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SpendingHistory {
pub direction: TransactionDirection,
pub amount: u64,
pub created: Vec<EventId>,
pub destroyed: Vec<EventId>,
pub redeemed: Vec<EventId>,
}
impl SpendingHistory {
pub fn new(direction: TransactionDirection, amount: u64) -> Self {
Self {
direction,
amount,
created: Vec::new(),
destroyed: Vec::new(),
redeemed: Vec::new(),
}
}
pub fn from_spending_history_event(
secret_key: &SecretKey,
public_key: &PublicKey,
event: &Event,
) -> Result<Self, Error> {
let decrypted: String = nip44::decrypt(secret_key, public_key, &event.content)?;
let data: Vec<Vec<String>> = serde_json::from_str(&decrypted)?;
let mut direction = None;
let mut amount = None;
let mut created: Vec<EventId> = Vec::new();
let mut destroyed: Vec<EventId> = Vec::new();
let mut redeemed: Vec<EventId> = Vec::new();
for item in data {
if item.len() >= 2 {
match item[0].as_str() {
DIRECTION => {
direction = Some(TransactionDirection::from_str(&item[1])?);
}
AMOUNT => {
amount = Some(item[1].parse().map_err(|_| Error::InvalidAmount)?);
}
E_TAG_STR if item.len() >= 4 => {
let event_id: EventId = EventId::from_hex(&item[1])?;
let marker: &str = &item[3];
match marker {
EVENT_MARKER_CREATED => created.push(event_id),
EVENT_MARKER_DESTROYED => destroyed.push(event_id),
_ => {}
}
}
_ => {}
}
}
}
for tag in event.tags.iter() {
let slice = tag.as_slice();
if slice.len() >= 4 && slice[0] == E_TAG_STR && slice[3] == EVENT_MARKER_REDEEMED {
redeemed.push(EventId::from_hex(&slice[1])?);
}
}
let direction: TransactionDirection =
direction.ok_or_else(|| Error::MissingField(DIRECTION.to_string()))?;
let amount: u64 = amount.ok_or_else(|| Error::MissingField(AMOUNT.to_string()))?;
Ok(Self {
direction,
amount,
created,
destroyed,
redeemed,
})
}
pub fn add_created(mut self, id: EventId) -> Self {
self.created.push(id);
self
}
pub fn add_destroyed(mut self, id: EventId) -> Self {
self.destroyed.push(id);
self
}
pub fn add_redeemed(mut self, id: EventId) -> Self {
self.redeemed.push(id);
self
}
#[cfg(feature = "std")]
fn to_encrypted_content(
&self,
secret_key: &SecretKey,
public_key: &PublicKey,
) -> Result<String, Error> {
let mut data: Vec<Vec<String>> = vec![
vec![DIRECTION.to_string(), self.direction.to_string()],
vec![AMOUNT.to_string(), self.amount.to_string()],
];
for event_id in &self.created {
let tag: Tag = Tag::custom(
TagKind::e(),
[
event_id.to_hex(),
String::new(),
EVENT_MARKER_CREATED.to_string(),
],
);
data.push(tag.to_vec());
}
for event_id in &self.destroyed {
let tag: Tag = Tag::custom(
TagKind::e(),
[
event_id.to_hex(),
String::new(),
EVENT_MARKER_DESTROYED.to_string(),
],
);
data.push(tag.to_vec());
}
let json: String = serde_json::to_string(&data)?;
Ok(nip44::encrypt(
secret_key,
public_key,
json,
nip44::Version::V2,
)?)
}
#[cfg(feature = "std")]
pub fn to_event_builder(
&self,
secret_key: &SecretKey,
public_key: &PublicKey,
) -> Result<EventBuilder, Error> {
let content: String = self.to_encrypted_content(secret_key, public_key)?;
let mut tags: Vec<Tag> = Vec::with_capacity(self.redeemed.len());
for event_id in self.redeemed.iter() {
tags.push(Tag::parse([
"e",
&event_id.to_hex(),
"",
EVENT_MARKER_REDEEMED,
])?);
}
Ok(EventBuilder::new(Kind::CashuWalletSpendingHistory, content).tags(tags))
}
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct QuoteEvent {
pub quote_id: String,
pub mint: Url,
pub expiration: Option<Timestamp>,
}
impl QuoteEvent {
pub fn new<S>(quote_id: S, mint: Url) -> Self
where
S: Into<String>,
{
Self {
quote_id: quote_id.into(),
mint,
expiration: None,
}
}
pub fn from_quote_event(
secret_key: &SecretKey,
public_key: &PublicKey,
event: &Event,
) -> Result<Self, Error> {
let quote_id: String = nip44::decrypt(secret_key, public_key, &event.content)?;
let mint: Url = event
.tags
.find(TagKind::custom(MINT))
.and_then(|tag| tag.content())
.ok_or(Error::MissingMintTag)?
.parse()
.map_err(|_| Error::InvalidMintUrl)?;
let expiration: Option<Timestamp> = event.tags.expiration().copied();
Ok(Self {
quote_id,
mint,
expiration,
})
}
pub fn expiration(mut self, expiration: Timestamp) -> Self {
self.expiration = Some(expiration);
self
}
#[cfg(feature = "std")]
fn to_encrypted_content(
&self,
secret_key: &SecretKey,
public_key: &PublicKey,
) -> Result<String, Error> {
Ok(nip44::encrypt(
secret_key,
public_key,
&self.quote_id,
nip44::Version::V2,
)?)
}
#[cfg(feature = "std")]
pub fn to_event_builder(
&self,
secret_key: &SecretKey,
public_key: &PublicKey,
) -> Result<EventBuilder, Error> {
let content: String = self.to_encrypted_content(secret_key, public_key)?;
let mut tags: Vec<Tag> = Vec::with_capacity(2);
tags.push(Tag::custom(TagKind::custom(MINT), [self.mint.as_str()]));
let expiration: Timestamp = Timestamp::now() + 14 * 24 * 60 * 60; tags.push(Tag::expiration(expiration));
Ok(EventBuilder::new(Kind::CashuWalletQuote, content).tags(tags))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_transaction_direction() {
assert_eq!(TransactionDirection::In.to_string(), "in");
assert_eq!(TransactionDirection::Out.to_string(), "out");
assert_eq!(
TransactionDirection::from_str("in").unwrap(),
TransactionDirection::In
);
assert_eq!(
TransactionDirection::from_str("out").unwrap(),
TransactionDirection::Out
);
assert!(TransactionDirection::from_str("invalid").is_err());
}
#[test]
fn test_wallet_event() {
let mint_url = Url::parse("https://example.com").unwrap();
let wallet = WalletEvent::new("test_privkey", vec![mint_url.clone()]);
assert_eq!(wallet.privkey, "test_privkey");
assert_eq!(wallet.mints.len(), 1);
assert_eq!(wallet.mints[0], mint_url);
}
#[test]
fn test_token_event_data() {
let mint_url = Url::parse("https://example.com").unwrap();
let proof = CashuProof {
id: "test_id".to_string(),
amount: 100,
secret: "test_secret".to_string(),
c: "test_c".to_string(),
};
let token_data = TokenEvent::new(mint_url.clone(), vec![proof.clone()]);
assert_eq!(token_data.mint, mint_url);
assert_eq!(token_data.proofs.len(), 1);
assert_eq!(token_data.proofs[0], proof);
assert!(token_data.del.is_empty());
}
#[test]
fn test_spending_history_data() {
let history = SpendingHistory::new(TransactionDirection::In, 50);
assert_eq!(history.direction, TransactionDirection::In);
assert_eq!(history.amount, 50);
assert!(history.created.is_empty());
assert!(history.destroyed.is_empty());
assert!(history.redeemed.is_empty());
}
#[test]
fn test_spending_history_with_references() {
let id1 = EventId::all_zeros();
let id2 = EventId::from_slice(&[
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 1,
])
.unwrap();
let id3 = EventId::from_slice(&[
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 2,
])
.unwrap();
let history = SpendingHistory::new(TransactionDirection::In, 50)
.add_created(id1)
.add_destroyed(id2)
.add_redeemed(id3);
assert_eq!(history.direction, TransactionDirection::In);
assert_eq!(history.amount, 50);
assert_eq!(history.created.len(), 1);
assert_eq!(history.destroyed.len(), 1);
assert_eq!(history.redeemed.len(), 1);
assert_eq!(history.created[0], id1);
assert_eq!(history.destroyed[0], id2);
assert_eq!(history.redeemed[0], id3);
}
#[test]
fn test_quote_event_data() {
let mint_url = Url::parse("https://example.com").unwrap();
let quote = QuoteEvent::new("test_quote_id", mint_url.clone());
assert_eq!(quote.quote_id, "test_quote_id");
assert_eq!(quote.mint, mint_url);
assert!(quote.expiration.is_none());
}
}