| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445 |
- //! NUT-05: Melting Tokens
- //!
- //! <https://github.com/cashubtc/nuts/blob/main/05.md>
- use std::fmt;
- use std::str::FromStr;
- use serde::de::{self, DeserializeOwned, Deserializer, MapAccess, Visitor};
- use serde::ser::{SerializeStruct, Serializer};
- use serde::{Deserialize, Serialize};
- use thiserror::Error;
- #[cfg(feature = "mint")]
- use uuid::Uuid;
- use super::nut00::{BlindedMessage, CurrencyUnit, PaymentMethod, Proofs};
- use super::ProofsMethods;
- use crate::Amount;
- /// NUT05 Error
- #[derive(Debug, Error)]
- pub enum Error {
- /// Unknown Quote State
- #[error("Unknown quote state")]
- UnknownState,
- /// Amount overflow
- #[error("Amount Overflow")]
- AmountOverflow,
- /// Unsupported unit
- #[error("Unsupported unit")]
- UnsupportedUnit,
- }
- /// Possible states of a quote
- #[derive(Debug, Clone, Copy, Hash, PartialEq, Eq, Default, Serialize, Deserialize)]
- #[serde(rename_all = "UPPERCASE")]
- #[cfg_attr(feature = "swagger", derive(utoipa::ToSchema), schema(as = MeltQuoteState))]
- pub enum QuoteState {
- /// Quote has not been paid
- #[default]
- Unpaid,
- /// Quote has been paid
- Paid,
- /// Paying quote is in progress
- Pending,
- /// Unknown state
- Unknown,
- /// Failed
- Failed,
- }
- impl fmt::Display for QuoteState {
- fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
- match self {
- Self::Unpaid => write!(f, "UNPAID"),
- Self::Paid => write!(f, "PAID"),
- Self::Pending => write!(f, "PENDING"),
- Self::Unknown => write!(f, "UNKNOWN"),
- Self::Failed => write!(f, "FAILED"),
- }
- }
- }
- impl FromStr for QuoteState {
- type Err = Error;
- fn from_str(state: &str) -> Result<Self, Self::Err> {
- match state {
- "PENDING" => Ok(Self::Pending),
- "PAID" => Ok(Self::Paid),
- "UNPAID" => Ok(Self::Unpaid),
- "UNKNOWN" => Ok(Self::Unknown),
- "FAILED" => Ok(Self::Failed),
- _ => Err(Error::UnknownState),
- }
- }
- }
- /// Melt Bolt11 Request [NUT-05]
- #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
- #[cfg_attr(feature = "swagger", derive(utoipa::ToSchema))]
- #[serde(bound = "Q: Serialize + DeserializeOwned")]
- pub struct MeltRequest<Q> {
- /// Quote ID
- quote: Q,
- /// Proofs
- #[cfg_attr(feature = "swagger", schema(value_type = Vec<crate::Proof>))]
- inputs: Proofs,
- /// Blinded Message that can be used to return change [NUT-08]
- /// Amount field of BlindedMessages `SHOULD` be set to zero
- outputs: Option<Vec<BlindedMessage>>,
- }
- #[cfg(feature = "mint")]
- impl TryFrom<MeltRequest<String>> for MeltRequest<Uuid> {
- type Error = uuid::Error;
- fn try_from(value: MeltRequest<String>) -> Result<Self, Self::Error> {
- Ok(Self {
- quote: Uuid::from_str(&value.quote)?,
- inputs: value.inputs,
- outputs: value.outputs,
- })
- }
- }
- // Basic implementation without trait bounds
- impl<Q> MeltRequest<Q> {
- /// Quote Id
- pub fn quote_id(&self) -> &Q {
- &self.quote
- }
- /// Get inputs (proofs)
- pub fn inputs(&self) -> &Proofs {
- &self.inputs
- }
- /// Get mutable inputs (proofs)
- pub fn inputs_mut(&mut self) -> &mut Proofs {
- &mut self.inputs
- }
- /// Get outputs (blinded messages for change)
- pub fn outputs(&self) -> &Option<Vec<BlindedMessage>> {
- &self.outputs
- }
- }
- impl<Q: Serialize + DeserializeOwned> MeltRequest<Q> {
- /// Create new [`MeltRequest`]
- pub fn new(quote: Q, inputs: Proofs, outputs: Option<Vec<BlindedMessage>>) -> Self {
- Self {
- quote,
- inputs: inputs.without_dleqs(),
- outputs,
- }
- }
- /// Get quote
- pub fn quote(&self) -> &Q {
- &self.quote
- }
- /// Total [`Amount`] of [`Proofs`]
- pub fn inputs_amount(&self) -> Result<Amount, Error> {
- Amount::try_sum(self.inputs.iter().map(|proof| proof.amount))
- .map_err(|_| Error::AmountOverflow)
- }
- }
- /// Melt Method Settings
- #[derive(Debug, Default, Clone, PartialEq, Eq, Hash)]
- #[cfg_attr(feature = "swagger", derive(utoipa::ToSchema))]
- pub struct MeltMethodSettings {
- /// Payment Method e.g. bolt11
- pub method: PaymentMethod,
- /// Currency Unit e.g. sat
- pub unit: CurrencyUnit,
- /// Min Amount
- pub min_amount: Option<Amount>,
- /// Max Amount
- pub max_amount: Option<Amount>,
- /// Options
- pub options: Option<MeltMethodOptions>,
- }
- impl Serialize for MeltMethodSettings {
- fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
- where
- S: Serializer,
- {
- let mut num_fields = 3; // method and unit are always present
- if self.min_amount.is_some() {
- num_fields += 1;
- }
- if self.max_amount.is_some() {
- num_fields += 1;
- }
- let mut amountless_in_top_level = false;
- if let Some(MeltMethodOptions::Bolt11 { amountless }) = &self.options {
- if *amountless {
- num_fields += 1;
- amountless_in_top_level = true;
- }
- }
- let mut state = serializer.serialize_struct("MeltMethodSettings", num_fields)?;
- state.serialize_field("method", &self.method)?;
- state.serialize_field("unit", &self.unit)?;
- if let Some(min_amount) = &self.min_amount {
- state.serialize_field("min_amount", min_amount)?;
- }
- if let Some(max_amount) = &self.max_amount {
- state.serialize_field("max_amount", max_amount)?;
- }
- // If there's an amountless flag in Bolt11 options, add it at the top level
- if amountless_in_top_level {
- state.serialize_field("amountless", &true)?;
- }
- state.end()
- }
- }
- struct MeltMethodSettingsVisitor;
- impl<'de> Visitor<'de> for MeltMethodSettingsVisitor {
- type Value = MeltMethodSettings;
- fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
- formatter.write_str("a MeltMethodSettings structure")
- }
- fn visit_map<M>(self, mut map: M) -> Result<Self::Value, M::Error>
- where
- M: MapAccess<'de>,
- {
- let mut method: Option<PaymentMethod> = None;
- let mut unit: Option<CurrencyUnit> = None;
- let mut min_amount: Option<Amount> = None;
- let mut max_amount: Option<Amount> = None;
- let mut amountless: Option<bool> = None;
- while let Some(key) = map.next_key::<String>()? {
- match key.as_str() {
- "method" => {
- if method.is_some() {
- return Err(de::Error::duplicate_field("method"));
- }
- method = Some(map.next_value()?);
- }
- "unit" => {
- if unit.is_some() {
- return Err(de::Error::duplicate_field("unit"));
- }
- unit = Some(map.next_value()?);
- }
- "min_amount" => {
- if min_amount.is_some() {
- return Err(de::Error::duplicate_field("min_amount"));
- }
- min_amount = Some(map.next_value()?);
- }
- "max_amount" => {
- if max_amount.is_some() {
- return Err(de::Error::duplicate_field("max_amount"));
- }
- max_amount = Some(map.next_value()?);
- }
- "amountless" => {
- if amountless.is_some() {
- return Err(de::Error::duplicate_field("amountless"));
- }
- amountless = Some(map.next_value()?);
- }
- "options" => {
- // If there are explicit options, they take precedence, except the amountless
- // field which we will handle specially
- let options: Option<MeltMethodOptions> = map.next_value()?;
- if let Some(MeltMethodOptions::Bolt11 {
- amountless: amountless_from_options,
- }) = options
- {
- // If we already found a top-level amountless, use that instead
- if amountless.is_none() {
- amountless = Some(amountless_from_options);
- }
- }
- }
- _ => {
- // Skip unknown fields
- let _: serde::de::IgnoredAny = map.next_value()?;
- }
- }
- }
- let method = method.ok_or_else(|| de::Error::missing_field("method"))?;
- let unit = unit.ok_or_else(|| de::Error::missing_field("unit"))?;
- // Create options based on the method and the amountless flag
- let options = if method == PaymentMethod::Bolt11 && amountless.is_some() {
- amountless.map(|amountless| MeltMethodOptions::Bolt11 { amountless })
- } else {
- None
- };
- Ok(MeltMethodSettings {
- method,
- unit,
- min_amount,
- max_amount,
- options,
- })
- }
- }
- impl<'de> Deserialize<'de> for MeltMethodSettings {
- fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
- where
- D: Deserializer<'de>,
- {
- deserializer.deserialize_map(MeltMethodSettingsVisitor)
- }
- }
- /// Mint Method settings options
- #[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
- #[cfg_attr(feature = "swagger", derive(utoipa::ToSchema))]
- #[serde(untagged)]
- pub enum MeltMethodOptions {
- /// Bolt11 Options
- Bolt11 {
- /// Mint supports paying bolt11 amountless
- amountless: bool,
- },
- }
- impl Settings {
- /// Create new [`Settings`]
- pub fn new(methods: Vec<MeltMethodSettings>, disabled: bool) -> Self {
- Self { methods, disabled }
- }
- /// Get [`MeltMethodSettings`] for unit method pair
- pub fn get_settings(
- &self,
- unit: &CurrencyUnit,
- method: &PaymentMethod,
- ) -> Option<MeltMethodSettings> {
- for method_settings in self.methods.iter() {
- if method_settings.method.eq(method) && method_settings.unit.eq(unit) {
- return Some(method_settings.clone());
- }
- }
- None
- }
- /// Remove [`MeltMethodSettings`] for unit method pair
- pub fn remove_settings(
- &mut self,
- unit: &CurrencyUnit,
- method: &PaymentMethod,
- ) -> Option<MeltMethodSettings> {
- self.methods
- .iter()
- .position(|settings| settings.method.eq(method) && settings.unit.eq(unit))
- .map(|index| self.methods.remove(index))
- }
- }
- /// Melt Settings
- #[derive(Debug, Clone, PartialEq, Eq, Hash, Default, Serialize, Deserialize)]
- #[cfg_attr(feature = "swagger", derive(utoipa::ToSchema), schema(as = nut05::Settings))]
- pub struct Settings {
- /// Methods to melt
- pub methods: Vec<MeltMethodSettings>,
- /// Minting disabled
- pub disabled: bool,
- }
- impl Settings {
- /// Supported nut05 methods
- pub fn supported_methods(&self) -> Vec<&PaymentMethod> {
- self.methods.iter().map(|a| &a.method).collect()
- }
- /// Supported nut05 units
- pub fn supported_units(&self) -> Vec<&CurrencyUnit> {
- self.methods.iter().map(|s| &s.unit).collect()
- }
- }
- #[cfg(test)]
- mod tests {
- use serde_json::{from_str, json, to_string};
- use super::*;
- #[test]
- fn test_melt_method_settings_top_level_amountless() {
- // Create JSON with top-level amountless
- let json_str = r#"{
- "method": "bolt11",
- "unit": "sat",
- "min_amount": 0,
- "max_amount": 10000,
- "amountless": true
- }"#;
- // Deserialize it
- let settings: MeltMethodSettings = from_str(json_str).unwrap();
- // Check that amountless was correctly moved to options
- assert_eq!(settings.method, PaymentMethod::Bolt11);
- assert_eq!(settings.unit, CurrencyUnit::Sat);
- assert_eq!(settings.min_amount, Some(Amount::from(0)));
- assert_eq!(settings.max_amount, Some(Amount::from(10000)));
- match settings.options {
- Some(MeltMethodOptions::Bolt11 { amountless }) => {
- assert!(amountless);
- }
- _ => panic!("Expected Bolt11 options with amountless = true"),
- }
- // Serialize it back
- let serialized = to_string(&settings).unwrap();
- let parsed: serde_json::Value = from_str(&serialized).unwrap();
- // Verify the amountless is at the top level
- assert_eq!(parsed["amountless"], json!(true));
- }
- #[test]
- fn test_both_amountless_locations() {
- // Create JSON with amountless in both places (top level and in options)
- let json_str = r#"{
- "method": "bolt11",
- "unit": "sat",
- "min_amount": 0,
- "max_amount": 10000,
- "amountless": true,
- "options": {
- "amountless": false
- }
- }"#;
- // Deserialize it - top level should take precedence
- let settings: MeltMethodSettings = from_str(json_str).unwrap();
- match settings.options {
- Some(MeltMethodOptions::Bolt11 { amountless }) => {
- assert!(amountless, "Top-level amountless should take precedence");
- }
- _ => panic!("Expected Bolt11 options with amountless = true"),
- }
- }
- }
|