use std::collections::{HashMap, HashSet};
use serde::{Deserialize, Deserializer, Serialize, Serializer};
use super::nut01::PublicKey;
use super::nut17::SupportedMethods;
use super::nut19::CachedEndpoint;
use super::{
nut04, nut05, nut15, nut19, nut29, AuthRequired, BlindAuthSettings, ClearAuthSettings,
MppMethodSettings, ProtectedEndpoint,
};
use crate::util::serde_helpers::deserialize_empty_string_as_none;
use crate::CurrencyUnit;
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct MintVersion {
pub name: String,
pub version: String,
}
impl MintVersion {
pub fn new(name: String, version: String) -> Self {
Self { name, version }
}
}
impl std::fmt::Display for MintVersion {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}/{}", self.name, self.version)
}
}
impl Serialize for MintVersion {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
let combined = format!("{}/{}", self.name, self.version);
serializer.serialize_str(&combined)
}
}
impl<'de> Deserialize<'de> for MintVersion {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: Deserializer<'de>,
{
let combined = String::deserialize(deserializer)?;
let parts: Vec<&str> = combined.split('/').collect();
if parts.len() != 2 {
return Err(serde::de::Error::custom("Invalid input string"));
}
Ok(MintVersion {
name: parts[0].to_string(),
version: parts[1].to_string(),
})
}
}
#[derive(Default, Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct MintInfo {
#[serde(skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
#[serde(
default,
skip_serializing_if = "Option::is_none",
deserialize_with = "deserialize_empty_string_as_none"
)]
pub pubkey: Option<PublicKey>,
#[serde(skip_serializing_if = "Option::is_none")]
pub version: Option<MintVersion>,
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub description_long: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub contact: Option<Vec<ContactInfo>>,
pub nuts: Nuts,
#[serde(skip_serializing_if = "Option::is_none")]
pub icon_url: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub urls: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub motd: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub time: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub tos_url: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub max_array_length: Option<u64>,
}
impl MintInfo {
pub fn new() -> Self {
Self::default()
}
pub fn name<S>(self, name: S) -> Self
where
S: Into<String>,
{
Self {
name: Some(name.into()),
..self
}
}
pub fn pubkey(self, pubkey: PublicKey) -> Self {
Self {
pubkey: Some(pubkey),
..self
}
}
pub fn version(self, mint_version: MintVersion) -> Self {
Self {
version: Some(mint_version),
..self
}
}
pub fn description<S>(self, description: S) -> Self
where
S: Into<String>,
{
Self {
description: Some(description.into()),
..self
}
}
pub fn long_description<S>(self, description_long: S) -> Self
where
S: Into<String>,
{
Self {
description_long: Some(description_long.into()),
..self
}
}
pub fn contact_info(self, contact_info: Vec<ContactInfo>) -> Self {
Self {
contact: Some(contact_info),
..self
}
}
pub fn nuts(self, nuts: Nuts) -> Self {
Self { nuts, ..self }
}
pub fn icon_url<S>(self, icon_url: S) -> Self
where
S: Into<String>,
{
Self {
icon_url: Some(icon_url.into()),
..self
}
}
pub fn motd<S>(self, motd: S) -> Self
where
S: Into<String>,
{
Self {
motd: Some(motd.into()),
..self
}
}
pub fn time<S>(self, time: S) -> Self
where
S: Into<u64>,
{
Self {
time: Some(time.into()),
..self
}
}
pub fn tos_url<S>(self, tos_url: S) -> Self
where
S: Into<String>,
{
Self {
tos_url: Some(tos_url.into()),
..self
}
}
pub fn max_array_length(self, max_array_length: u64) -> Self {
Self {
max_array_length: Some(max_array_length),
..self
}
}
pub fn protected_endpoints(&self) -> HashMap<ProtectedEndpoint, AuthRequired> {
let mut protected_endpoints = HashMap::new();
if let Some(nut21_settings) = &self.nuts.nut21 {
for endpoint in nut21_settings.protected_endpoints.iter() {
protected_endpoints.insert(endpoint.clone(), AuthRequired::Clear);
}
}
if let Some(nut22_settings) = &self.nuts.nut22 {
for endpoint in nut22_settings.protected_endpoints.iter() {
protected_endpoints.insert(endpoint.clone(), AuthRequired::Blind);
}
}
protected_endpoints
}
pub fn openid_discovery(&self) -> Option<String> {
self.nuts
.nut21
.as_ref()
.map(|s| s.openid_discovery.to_string())
}
pub fn client_id(&self) -> Option<String> {
self.nuts.nut21.as_ref().map(|s| s.client_id.clone())
}
pub fn bat_max_mint(&self) -> Option<u64> {
self.nuts.nut22.as_ref().map(|s| s.bat_max_mint)
}
pub fn supported_units(&self) -> Vec<&CurrencyUnit> {
let mut units = HashSet::new();
units.extend(self.nuts.supported_mint_units());
units.extend(self.nuts.supported_melt_units());
units.into_iter().collect()
}
}
#[derive(Debug, Default, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct Nuts {
#[serde(default)]
#[serde(rename = "4")]
pub nut04: nut04::Settings,
#[serde(default)]
#[serde(rename = "5")]
pub nut05: nut05::Settings,
#[serde(default)]
#[serde(rename = "7")]
pub nut07: SupportedSettings,
#[serde(default)]
#[serde(rename = "8")]
pub nut08: SupportedSettings,
#[serde(default)]
#[serde(rename = "9")]
pub nut09: SupportedSettings,
#[serde(rename = "10")]
#[serde(default)]
pub nut10: SupportedSettings,
#[serde(rename = "11")]
#[serde(default)]
pub nut11: SupportedSettings,
#[serde(default)]
#[serde(rename = "12")]
pub nut12: SupportedSettings,
#[serde(default)]
#[serde(rename = "14")]
pub nut14: SupportedSettings,
#[serde(default)]
#[serde(rename = "15")]
#[serde(skip_serializing_if = "nut15::Settings::is_empty")]
pub nut15: nut15::Settings,
#[serde(default)]
#[serde(rename = "17")]
pub nut17: super::nut17::SupportedSettings,
#[serde(default)]
#[serde(rename = "19")]
pub nut19: nut19::Settings,
#[serde(default)]
#[serde(rename = "20")]
pub nut20: SupportedSettings,
#[serde(rename = "21")]
#[serde(skip_serializing_if = "Option::is_none")]
pub nut21: Option<ClearAuthSettings>,
#[serde(rename = "22")]
#[serde(skip_serializing_if = "Option::is_none")]
pub nut22: Option<BlindAuthSettings>,
#[serde(default)]
#[serde(rename = "29")]
#[serde(skip_serializing_if = "nut29::Settings::is_empty")]
pub nut29: nut29::Settings,
}
impl Nuts {
pub fn new() -> Self {
Self::default()
}
pub fn nut04(self, nut04_settings: nut04::Settings) -> Self {
Self {
nut04: nut04_settings,
..self
}
}
pub fn nut05(self, nut05_settings: nut05::Settings) -> Self {
Self {
nut05: nut05_settings,
..self
}
}
pub fn nut07(self, supported: bool) -> Self {
Self {
nut07: SupportedSettings { supported },
..self
}
}
pub fn nut08(self, supported: bool) -> Self {
Self {
nut08: SupportedSettings { supported },
..self
}
}
pub fn nut09(self, supported: bool) -> Self {
Self {
nut09: SupportedSettings { supported },
..self
}
}
pub fn nut10(self, supported: bool) -> Self {
Self {
nut10: SupportedSettings { supported },
..self
}
}
pub fn nut11(self, supported: bool) -> Self {
Self {
nut11: SupportedSettings { supported },
..self
}
}
pub fn nut12(self, supported: bool) -> Self {
Self {
nut12: SupportedSettings { supported },
..self
}
}
pub fn nut14(self, supported: bool) -> Self {
Self {
nut14: SupportedSettings { supported },
..self
}
}
pub fn nut15(self, mpp_settings: Vec<MppMethodSettings>) -> Self {
Self {
nut15: nut15::Settings {
methods: mpp_settings,
},
..self
}
}
pub fn nut17(self, supported: Vec<SupportedMethods>) -> Self {
Self {
nut17: super::nut17::SupportedSettings { supported },
..self
}
}
pub fn nut19(self, ttl: Option<u64>, cached_endpoints: Vec<CachedEndpoint>) -> Self {
Self {
nut19: nut19::Settings {
ttl,
cached_endpoints,
},
..self
}
}
pub fn nut20(self, supported: bool) -> Self {
Self {
nut20: SupportedSettings { supported },
..self
}
}
pub fn nut29(self, settings: nut29::Settings) -> Self {
Self {
nut29: settings,
..self
}
}
pub fn supported_mint_units(&self) -> Vec<&CurrencyUnit> {
self.nut04
.methods
.iter()
.map(|s| &s.unit)
.collect::<HashSet<_>>()
.into_iter()
.collect()
}
pub fn supported_melt_units(&self) -> Vec<&CurrencyUnit> {
self.nut05
.methods
.iter()
.map(|s| &s.unit)
.collect::<HashSet<_>>()
.into_iter()
.collect()
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Hash, Serialize, Deserialize)]
pub struct SupportedSettings {
pub supported: bool,
}
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct ContactInfo {
pub method: String,
pub info: String,
}
impl ContactInfo {
pub fn new(method: String, info: String) -> Self {
Self { method, info }
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::nut00::KnownMethod;
use crate::nut04::MintMethodOptions;
use crate::{Amount, Method, PaymentMethod, RoutePath};
#[test]
fn test_des_mint_into() {
let mint_info_str = r#"{
"name": "Cashu mint",
"pubkey": "0296d0aa13b6a31cf0cd974249f28c7b7176d7274712c95a41c7d8066d3f29d679",
"version": "Nutshell/0.15.3",
"contact": [
["", ""],
["", ""]
],
"nuts": {
"4": {
"methods": [
{"method": "bolt11", "unit": "sat", "description": true},
{"method": "bolt11", "unit": "usd", "description": true}
],
"disabled": false
},
"5": {
"methods": [
{"method": "bolt11", "unit": "sat"},
{"method": "bolt11", "unit": "usd"}
],
"disabled": false
},
"7": {"supported": true},
"8": {"supported": true},
"9": {"supported": true},
"10": {"supported": true},
"11": {"supported": true}
},
"tos_url": "https://cashu.mint/tos"
}"#;
let _mint_info: MintInfo = serde_json::from_str(mint_info_str).unwrap();
}
#[test]
fn test_ser_mint_info() {
let mint_info_str = r#"
{
"name": "Bob's Cashu mint",
"pubkey": "0283bf290884eed3a7ca2663fc0260de2e2064d6b355ea13f98dec004b7a7ead99",
"version": "Nutshell/0.15.0",
"description": "The short mint description",
"description_long": "A description that can be a long piece of text.",
"contact": [
{
"method": "nostr",
"info": "xxxxx"
},
{
"method": "email",
"info": "contact@me.com"
}
],
"motd": "Message to display to users.",
"icon_url": "https://this-is-a-mint-icon-url.com/icon.png",
"nuts": {
"4": {
"methods": [
{
"method": "bolt11",
"unit": "sat",
"min_amount": 0,
"max_amount": 10000,
"options": {
"description": true
}
}
],
"disabled": false
},
"5": {
"methods": [
{
"method": "bolt11",
"unit": "sat",
"min_amount": 0,
"max_amount": 10000
}
],
"disabled": false
},
"7": {"supported": true},
"8": {"supported": true},
"9": {"supported": true},
"10": {"supported": true},
"12": {"supported": true}
},
"tos_url": "https://cashu.mint/tos"
}"#;
let info: MintInfo = serde_json::from_str(mint_info_str).unwrap();
let mint_info_str = r#"
{
"name": "Bob's Cashu mint",
"pubkey": "0283bf290884eed3a7ca2663fc0260de2e2064d6b355ea13f98dec004b7a7ead99",
"version": "Nutshell/0.15.0",
"description": "The short mint description",
"description_long": "A description that can be a long piece of text.",
"contact": [
["nostr", "xxxxx"],
["email", "contact@me.com"]
],
"motd": "Message to display to users.",
"icon_url": "https://this-is-a-mint-icon-url.com/icon.png",
"nuts": {
"4": {
"methods": [
{
"method": "bolt11",
"unit": "sat",
"min_amount": 0,
"max_amount": 10000,
"options": {
"description": true
}
}
],
"disabled": false
},
"5": {
"methods": [
{
"method": "bolt11",
"unit": "sat",
"min_amount": 0,
"max_amount": 10000
}
],
"disabled": false
},
"7": {"supported": true},
"8": {"supported": true},
"9": {"supported": true},
"10": {"supported": true},
"12": {"supported": true}
},
"tos_url": "https://cashu.mint/tos"
}"#;
let mint_info: MintInfo = serde_json::from_str(mint_info_str).unwrap();
let t = mint_info
.nuts
.nut04
.get_settings(
&crate::CurrencyUnit::Sat,
&crate::PaymentMethod::Known(KnownMethod::Bolt11),
)
.unwrap();
let t = t.options.unwrap();
matches!(t, MintMethodOptions::Bolt11 { description: true });
assert_eq!(info, mint_info);
}
#[test]
fn test_nut15_not_serialized_when_empty() {
let mint_info = MintInfo {
name: Some("Test Mint".to_string()),
nuts: Nuts::default(),
..Default::default()
};
let json = serde_json::to_string(&mint_info).unwrap();
let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
assert!(parsed["nuts"]["15"].is_null());
let mint_info_with_nut15 = MintInfo {
name: Some("Test Mint".to_string()),
nuts: Nuts::default().nut15(vec![MppMethodSettings {
method: crate::PaymentMethod::Known(KnownMethod::Bolt11),
unit: crate::CurrencyUnit::Sat,
}]),
..Default::default()
};
let json = serde_json::to_string(&mint_info_with_nut15).unwrap();
let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
assert!(!parsed["nuts"]["15"].is_null());
assert!(parsed["nuts"]["15"]["methods"].is_array());
assert_eq!(parsed["nuts"]["15"]["methods"].as_array().unwrap().len(), 1);
}
#[test]
fn mint_version_display_uses_name_and_version() {
let version = MintVersion::new("cdk".to_string(), "1.2.3".to_string());
assert_eq!(version.to_string(), "cdk/1.2.3");
}
#[test]
fn mint_info_builder_preserves_all_fields() {
let pubkey = PublicKey::from_hex(
"0283bf290884eed3a7ca2663fc0260de2e2064d6b355ea13f98dec004b7a7ead99",
)
.unwrap();
let contact = vec![ContactInfo::new(
"email".to_string(),
"mint@example.com".to_string(),
)];
let nuts = Nuts::new().nut07(true).nut08(true);
let info = MintInfo::new()
.name("Test mint")
.pubkey(pubkey)
.version(MintVersion::new("cdk".to_string(), "1.2.3".to_string()))
.description("short")
.long_description("long")
.contact_info(contact.clone())
.nuts(nuts.clone())
.icon_url("https://example.com/icon.png")
.motd("hello")
.time(123_u64)
.tos_url("https://example.com/tos")
.max_array_length(1000);
assert_eq!(info.name.as_deref(), Some("Test mint"));
assert_eq!(info.pubkey, Some(pubkey));
assert_eq!(
info.version.as_ref().map(ToString::to_string).as_deref(),
Some("cdk/1.2.3")
);
assert_eq!(info.description.as_deref(), Some("short"));
assert_eq!(info.description_long.as_deref(), Some("long"));
assert_eq!(info.contact, Some(contact));
assert_eq!(info.nuts, nuts);
assert_eq!(
info.icon_url.as_deref(),
Some("https://example.com/icon.png")
);
assert_eq!(info.motd.as_deref(), Some("hello"));
assert_eq!(info.time, Some(123));
assert_eq!(info.tos_url.as_deref(), Some("https://example.com/tos"));
assert_eq!(info.max_array_length, Some(1000));
}
#[test]
fn mint_info_auth_helpers_return_configured_values() {
let clear_endpoint = ProtectedEndpoint::new(Method::Get, RoutePath::Swap);
let blind_endpoint = ProtectedEndpoint::new(
Method::Post,
RoutePath::Mint(PaymentMethod::Known(KnownMethod::Bolt11).to_string()),
);
let info = MintInfo {
nuts: Nuts {
nut21: Some(ClearAuthSettings::new(
"https://issuer.example/.well-known/openid-configuration".to_string(),
"wallet-client".to_string(),
vec![clear_endpoint.clone()],
)),
nut22: Some(BlindAuthSettings::new(42, vec![blind_endpoint.clone()])),
..Default::default()
},
..Default::default()
};
let protected = info.protected_endpoints();
assert_eq!(
info.openid_discovery().as_deref(),
Some("https://issuer.example/.well-known/openid-configuration")
);
assert_eq!(info.client_id().as_deref(), Some("wallet-client"));
assert_eq!(info.bat_max_mint(), Some(42));
assert_eq!(protected.get(&clear_endpoint), Some(&AuthRequired::Clear));
assert_eq!(protected.get(&blind_endpoint), Some(&AuthRequired::Blind));
}
#[test]
fn nuts_builder_preserves_capabilities_and_supported_units() {
let bolt11 = PaymentMethod::Known(KnownMethod::Bolt11);
let bolt12 = PaymentMethod::Known(KnownMethod::Bolt12);
let mint_settings = nut04::Settings::new(
vec![nut04::MintMethodSettings {
method: bolt11.clone(),
unit: CurrencyUnit::Eur,
method_name: Some("Lightning".to_string()),
min_amount: Some(Amount::from(1)),
max_amount: Some(Amount::from(10)),
options: Some(MintMethodOptions::Bolt11 { description: true }),
}],
false,
);
let melt_settings = nut05::Settings::new(
vec![nut05::MeltMethodSettings {
method: bolt12,
unit: CurrencyUnit::Usd,
method_name: None,
min_amount: Some(Amount::from(2)),
max_amount: Some(Amount::from(20)),
options: None,
}],
false,
);
let mpp = vec![MppMethodSettings {
method: bolt11.clone(),
unit: CurrencyUnit::Sat,
}];
let supported_ws = vec![SupportedMethods::default_bolt11(CurrencyUnit::Sat)];
let cached = vec![CachedEndpoint::new(nut19::Method::Get, nut19::Path::Swap)];
let nut29 = nut29::Settings::new(Some(3), Some(vec!["bolt11".to_string()]));
let nuts = Nuts::new()
.nut04(mint_settings.clone())
.nut05(melt_settings.clone())
.nut07(true)
.nut08(true)
.nut09(true)
.nut10(true)
.nut11(true)
.nut12(true)
.nut14(true)
.nut15(mpp.clone())
.nut17(supported_ws.clone())
.nut19(Some(60), cached.clone())
.nut20(true)
.nut29(nut29.clone());
assert_eq!(nuts.nut04, mint_settings);
assert_eq!(nuts.nut05, melt_settings);
assert!(nuts.nut07.supported);
assert!(nuts.nut08.supported);
assert!(nuts.nut09.supported);
assert!(nuts.nut10.supported);
assert!(nuts.nut11.supported);
assert!(nuts.nut12.supported);
assert!(nuts.nut14.supported);
assert_eq!(nuts.nut15.methods, mpp);
assert_eq!(nuts.nut17.supported, supported_ws);
assert_eq!(nuts.nut19.ttl, Some(60));
assert_eq!(nuts.nut19.cached_endpoints, cached);
assert!(nuts.nut20.supported);
assert_eq!(nuts.nut29, nut29);
assert!(nuts.supported_mint_units().contains(&&CurrencyUnit::Eur));
assert!(nuts.supported_melt_units().contains(&&CurrencyUnit::Usd));
let info = MintInfo {
nuts,
..Default::default()
};
let supported_units = info.supported_units();
assert!(supported_units.contains(&&CurrencyUnit::Eur));
assert!(supported_units.contains(&&CurrencyUnit::Usd));
}
}