From 3920c6f9bc7e622e6e11f10434d97164aa0032bf Mon Sep 17 00:00:00 2001 From: thesimplekid Date: Sun, 18 May 2025 10:04:02 +0100 Subject: [PATCH] fix: nut18 payment request encoding/decoding (#758) * fix: nut18 payment request encoding/decoding * refactor: reorder nut18fns * refactor: reorder nut18 --- crates/cashu/src/nuts/nut10.rs | 112 ++++- crates/cashu/src/nuts/nut18/error.rs | 17 + crates/cashu/src/nuts/nut18/mod.rs | 11 + .../{nut18.rs => nut18/payment_request.rs} | 381 ++++++------------ crates/cashu/src/nuts/nut18/secret.rs | 137 +++++++ crates/cashu/src/nuts/nut18/transport.rs | 126 ++++++ 6 files changed, 530 insertions(+), 254 deletions(-) create mode 100644 crates/cashu/src/nuts/nut18/error.rs create mode 100644 crates/cashu/src/nuts/nut18/mod.rs rename crates/cashu/src/nuts/{nut18.rs => nut18/payment_request.rs} (67%) create mode 100644 crates/cashu/src/nuts/nut18/secret.rs create mode 100644 crates/cashu/src/nuts/nut18/transport.rs diff --git a/crates/cashu/src/nuts/nut10.rs b/crates/cashu/src/nuts/nut10.rs index 541095c2..86b1919c 100644 --- a/crates/cashu/src/nuts/nut10.rs +++ b/crates/cashu/src/nuts/nut10.rs @@ -2,8 +2,10 @@ //! //! +use std::fmt; use std::str::FromStr; +use serde::de::{self, Deserializer, SeqAccess, Visitor}; use serde::ser::SerializeTuple; use serde::{Deserialize, Serialize, Serializer}; use thiserror::Error; @@ -41,7 +43,7 @@ pub struct SecretData { } /// NUT10 Secret -#[derive(Debug, Clone, PartialEq, Eq, Hash, Deserialize)] +#[derive(Debug, Clone, PartialEq, Eq, Hash)] pub struct Secret { /// Kind of the spending condition pub kind: Kind, @@ -94,9 +96,52 @@ impl TryFrom for crate::secret::Secret { } } +// Custom visitor for deserializing Secret +struct SecretVisitor; + +impl<'de> Visitor<'de> for SecretVisitor { + type Value = Secret; + + fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result { + formatter.write_str("a tuple with two elements: [Kind, SecretData]") + } + + fn visit_seq(self, mut seq: A) -> Result + where + A: SeqAccess<'de>, + { + // Deserialize the kind (first element) + let kind = seq + .next_element()? + .ok_or_else(|| de::Error::invalid_length(0, &self))?; + + // Deserialize the secret_data (second element) + let secret_data = seq + .next_element()? + .ok_or_else(|| de::Error::invalid_length(1, &self))?; + + // Make sure there are no additional elements + if seq.next_element::()?.is_some() { + return Err(de::Error::invalid_length(3, &self)); + } + + Ok(Secret { kind, secret_data }) + } +} + +impl<'de> Deserialize<'de> for Secret { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + deserializer.deserialize_seq(SecretVisitor) + } +} + #[cfg(test)] mod tests { use std::assert_eq; + use std::str::FromStr; use super::*; @@ -120,4 +165,69 @@ mod tests { assert_eq!(serde_json::to_string(&secret).unwrap(), secret_str); } + + #[test] + fn test_secret_round_trip_serialization() { + // Create a Secret instance + let original_secret = Secret { + kind: Kind::P2PK, + secret_data: SecretData { + nonce: "5d11913ee0f92fefdc82a6764fd2457a".to_string(), + data: "026562efcfadc8e86d44da6a8adf80633d974302e62c850774db1fb36ff4cc7198" + .to_string(), + tags: None, + }, + }; + + // Serialize the Secret to JSON string + let serialized = serde_json::to_string(&original_secret).unwrap(); + + // Deserialize directly back to Secret using serde + let deserialized_secret: Secret = serde_json::from_str(&serialized).unwrap(); + + // Verify the direct serde serialization/deserialization round trip works + assert_eq!(original_secret, deserialized_secret); + + // Also verify that the conversion to crate::secret::Secret works + let cashu_secret = crate::secret::Secret::from_str(&serialized).unwrap(); + let deserialized_from_cashu: Secret = TryFrom::try_from(&cashu_secret).unwrap(); + assert_eq!(original_secret, deserialized_from_cashu); + } + + #[test] + fn test_htlc_secret_round_trip() { + // The reference BOLT11 invoice is: + // lnbc100n1p5z3a63pp56854ytysg7e5z9fl3w5mgvrlqjfcytnjv8ff5hm5qt6gl6alxesqdqqcqzzsxqyz5vqsp5p0x0dlhn27s63j4emxnk26p7f94u0lyarnfp5yqmac9gzy4ngdss9qxpqysgqne3v0hnzt2lp0hc69xpzckk0cdcar7glvjhq60lsrfe8gejdm8c564prrnsft6ctxxyrewp4jtezrq3gxxqnfjj0f9tw2qs9y0lslmqpfu7et9 + + // Payment hash (typical 32 byte hash in hex format) + let payment_hash = "5c23fc3aec9d985bd5fc88ca8bceaccc52cf892715dd94b42b84f1b43350751e"; + + // Create a Secret instance with HTLC kind + let original_secret = Secret { + kind: Kind::HTLC, + secret_data: SecretData { + nonce: "7a9128b3f9612549f9278958337a5d7f".to_string(), + data: payment_hash.to_string(), + tags: None, + }, + }; + + // Serialize the Secret to JSON string + let serialized = serde_json::to_string(&original_secret).unwrap(); + + // Validate serialized format + let expected_json = format!( + r#"["HTLC",{{"nonce":"7a9128b3f9612549f9278958337a5d7f","data":"{}"}}]"#, + payment_hash + ); + assert_eq!(serialized, expected_json); + + // Deserialize directly back to Secret using serde + let deserialized_secret: Secret = serde_json::from_str(&serialized).unwrap(); + + // Verify the direct serde serialization/deserialization round trip works + assert_eq!(original_secret, deserialized_secret); + assert_eq!(deserialized_secret.kind, Kind::HTLC); + assert_eq!(deserialized_secret.secret_data.data, payment_hash); + } } diff --git a/crates/cashu/src/nuts/nut18/error.rs b/crates/cashu/src/nuts/nut18/error.rs new file mode 100644 index 00000000..8f379fb6 --- /dev/null +++ b/crates/cashu/src/nuts/nut18/error.rs @@ -0,0 +1,17 @@ +//! Error types for NUT-18: Payment Requests + +use thiserror::Error; + +/// NUT18 Error +#[derive(Debug, Error)] +pub enum Error { + /// Invalid Prefix + #[error("Invalid Prefix")] + InvalidPrefix, + /// Ciborium error + #[error(transparent)] + CiboriumError(#[from] ciborium::de::Error), + /// Base64 error + #[error(transparent)] + Base64Error(#[from] bitcoin::base64::DecodeError), +} diff --git a/crates/cashu/src/nuts/nut18/mod.rs b/crates/cashu/src/nuts/nut18/mod.rs new file mode 100644 index 00000000..a3e22db3 --- /dev/null +++ b/crates/cashu/src/nuts/nut18/mod.rs @@ -0,0 +1,11 @@ +//! NUT-18 module imports + +pub mod error; +pub mod payment_request; +pub mod secret; +pub mod transport; + +pub use error::Error; +pub use payment_request::{PaymentRequest, PaymentRequestBuilder, PaymentRequestPayload}; +pub use secret::{Nut10SecretRequest, SecretDataRequest}; +pub use transport::{Transport, TransportBuilder, TransportType}; diff --git a/crates/cashu/src/nuts/nut18.rs b/crates/cashu/src/nuts/nut18/payment_request.rs similarity index 67% rename from crates/cashu/src/nuts/nut18.rs rename to crates/cashu/src/nuts/nut18/payment_request.rs index 371c2c8a..17988029 100644 --- a/crates/cashu/src/nuts/nut18.rs +++ b/crates/cashu/src/nuts/nut18/payment_request.rs @@ -8,232 +8,15 @@ use std::str::FromStr; use bitcoin::base64::engine::{general_purpose, GeneralPurpose}; use bitcoin::base64::{alphabet, Engine}; -use serde::ser::{SerializeTuple, Serializer}; use serde::{Deserialize, Serialize}; -use thiserror::Error; -use super::{CurrencyUnit, Nut10Secret, Proofs, SpendingConditions}; +use super::{Error, Nut10SecretRequest, Transport}; use crate::mint_url::MintUrl; -use crate::nuts::nut10::Kind; +use crate::nuts::{CurrencyUnit, Proofs}; use crate::Amount; const PAYMENT_REQUEST_PREFIX: &str = "creqA"; -/// NUT18 Error -#[derive(Debug, Error)] -pub enum Error { - /// Invalid Prefix - #[error("Invalid Prefix")] - InvalidPrefix, - /// Ciborium error - #[error(transparent)] - CiboriumError(#[from] ciborium::de::Error), - /// Base64 error - #[error(transparent)] - Base64Error(#[from] bitcoin::base64::DecodeError), -} - -/// Transport Type -#[derive(Debug, Clone, Copy, Hash, PartialEq, Eq, Serialize, Deserialize)] -pub enum TransportType { - /// Nostr - #[serde(rename = "nostr")] - Nostr, - /// Http post - #[serde(rename = "post")] - HttpPost, -} - -impl fmt::Display for TransportType { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - use serde::ser::Error; - let t = serde_json::to_string(self).map_err(|e| fmt::Error::custom(e.to_string()))?; - write!(f, "{t}") - } -} - -impl FromStr for TransportType { - type Err = Error; - - fn from_str(s: &str) -> Result { - match s.to_lowercase().as_str() { - "nostr" => Ok(Self::Nostr), - "post" => Ok(Self::HttpPost), - _ => Err(Error::InvalidPrefix), - } - } -} - -impl FromStr for Transport { - type Err = Error; - - fn from_str(s: &str) -> Result { - let decode_config = general_purpose::GeneralPurposeConfig::new() - .with_decode_padding_mode(bitcoin::base64::engine::DecodePaddingMode::Indifferent); - let decoded = GeneralPurpose::new(&alphabet::URL_SAFE, decode_config).decode(s)?; - - Ok(ciborium::from_reader(&decoded[..])?) - } -} - -/// Transport -#[derive(Debug, Clone, Hash, PartialEq, Eq, Serialize, Deserialize)] -pub struct Transport { - /// Type - #[serde(rename = "t")] - pub _type: TransportType, - /// Target - #[serde(rename = "a")] - pub target: String, - /// Tags - #[serde(rename = "g")] - pub tags: Option>>, -} - -impl Transport { - /// Create a new TransportBuilder - pub fn builder() -> TransportBuilder { - TransportBuilder::default() - } -} - -/// Builder for Transport -#[derive(Debug, Default, Clone)] -pub struct TransportBuilder { - _type: Option, - target: Option, - tags: Option>>, -} - -impl TransportBuilder { - /// Set transport type - pub fn transport_type(mut self, transport_type: TransportType) -> Self { - self._type = Some(transport_type); - self - } - - /// Set target - pub fn target>(mut self, target: S) -> Self { - self.target = Some(target.into()); - self - } - - /// Add a tag - pub fn add_tag(mut self, tag: Vec) -> Self { - self.tags.get_or_insert_with(Vec::new).push(tag); - self - } - - /// Set tags - pub fn tags(mut self, tags: Vec>) -> Self { - self.tags = Some(tags); - self - } - - /// Build the Transport - pub fn build(self) -> Result { - let _type = self._type.ok_or("Transport type is required")?; - let target = self.target.ok_or("Target is required")?; - - Ok(Transport { - _type, - target, - tags: self.tags, - }) - } -} - -impl AsRef for Transport { - fn as_ref(&self) -> &String { - &self.target - } -} - -/// Secret Data without nonce for payment requests -#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] -pub struct SecretDataRequest { - /// Expresses the spending condition specific to each kind - pub data: String, - /// Additional data committed to and can be used for feature extensions - #[serde(skip_serializing_if = "Option::is_none")] - pub tags: Option>>, -} - -/// Nut10Secret without nonce for payment requests -#[derive(Debug, Clone, Hash, PartialEq, Eq, Deserialize)] -pub struct Nut10SecretRequest { - /// Kind of the spending condition - pub kind: Kind, - /// Secret Data without nonce - pub secret_data: SecretDataRequest, -} - -impl Nut10SecretRequest { - /// Create a new Nut10SecretRequest - pub fn new(kind: Kind, data: S, tags: Option) -> Self - where - S: Into, - V: Into>>, - { - let secret_data = SecretDataRequest { - data: data.into(), - tags: tags.map(|v| v.into()), - }; - - Self { kind, secret_data } - } -} - -impl From for Nut10SecretRequest { - fn from(secret: Nut10Secret) -> Self { - let secret_data = SecretDataRequest { - data: secret.secret_data.data, - tags: secret.secret_data.tags, - }; - - Self { - kind: secret.kind, - secret_data, - } - } -} - -impl From for Nut10Secret { - fn from(value: Nut10SecretRequest) -> Self { - Self::new(value.kind, value.secret_data.data, value.secret_data.tags) - } -} - -impl From for Nut10SecretRequest { - fn from(conditions: SpendingConditions) -> Self { - match conditions { - SpendingConditions::P2PKConditions { data, conditions } => { - Self::new(Kind::P2PK, data.to_hex(), conditions) - } - SpendingConditions::HTLCConditions { data, conditions } => { - Self::new(Kind::HTLC, data.to_string(), conditions) - } - } - } -} - -impl Serialize for Nut10SecretRequest { - fn serialize(&self, serializer: S) -> Result - where - S: Serializer, - { - // Create a tuple representing the struct fields - let secret_tuple = (&self.kind, &self.secret_data); - - // Serialize the tuple as a JSON array - let mut s = serializer.serialize_tuple(2)?; - - s.serialize_element(&secret_tuple.0)?; - s.serialize_element(&secret_tuple.1)?; - s.end() - } -} - /// Payment Request #[derive(Debug, Clone, Hash, PartialEq, Eq, Serialize, Deserialize)] pub struct PaymentRequest { @@ -270,6 +53,38 @@ impl PaymentRequest { } } +impl AsRef> for PaymentRequest { + fn as_ref(&self) -> &Option { + &self.payment_id + } +} + +impl fmt::Display for PaymentRequest { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + use serde::ser::Error; + let mut data = Vec::new(); + ciborium::into_writer(self, &mut data).map_err(|e| fmt::Error::custom(e.to_string()))?; + let encoded = general_purpose::URL_SAFE.encode(data); + write!(f, "{PAYMENT_REQUEST_PREFIX}{encoded}") + } +} + +impl FromStr for PaymentRequest { + type Err = Error; + + fn from_str(s: &str) -> Result { + let s = s + .strip_prefix(PAYMENT_REQUEST_PREFIX) + .ok_or(Error::InvalidPrefix)?; + + let decode_config = general_purpose::GeneralPurposeConfig::new() + .with_decode_padding_mode(bitcoin::base64::engine::DecodePaddingMode::Indifferent); + let decoded = GeneralPurpose::new(&alphabet::URL_SAFE, decode_config).decode(s)?; + + Ok(ciborium::from_reader(&decoded[..])?) + } +} + /// Builder for PaymentRequest #[derive(Debug, Default, Clone)] pub struct PaymentRequestBuilder { @@ -367,38 +182,6 @@ impl PaymentRequestBuilder { } } -impl AsRef> for PaymentRequest { - fn as_ref(&self) -> &Option { - &self.payment_id - } -} - -impl fmt::Display for PaymentRequest { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - use serde::ser::Error; - let mut data = Vec::new(); - ciborium::into_writer(self, &mut data).map_err(|e| fmt::Error::custom(e.to_string()))?; - let encoded = general_purpose::URL_SAFE.encode(data); - write!(f, "{PAYMENT_REQUEST_PREFIX}{encoded}") - } -} - -impl FromStr for PaymentRequest { - type Err = Error; - - fn from_str(s: &str) -> Result { - let s = s - .strip_prefix(PAYMENT_REQUEST_PREFIX) - .ok_or(Error::InvalidPrefix)?; - - let decode_config = general_purpose::GeneralPurposeConfig::new() - .with_decode_padding_mode(bitcoin::base64::engine::DecodePaddingMode::Indifferent); - let decoded = GeneralPurpose::new(&alphabet::URL_SAFE, decode_config).decode(s)?; - - Ok(ciborium::from_reader(&decoded[..])?) - } -} - /// Payment Request #[derive(Debug, Clone, Hash, PartialEq, Eq, Serialize, Deserialize)] pub struct PaymentRequestPayload { @@ -418,7 +201,12 @@ pub struct PaymentRequestPayload { mod tests { use std::str::FromStr; + use lightning_invoice::Bolt11Invoice; + use super::*; + use crate::nuts::nut10::Kind; + use crate::nuts::SpendingConditions; + use crate::TransportType; const PAYMENT_REQUEST: &str = "creqApWF0gaNhdGVub3N0cmFheKlucHJvZmlsZTFxeTI4d3VtbjhnaGo3dW45ZDNzaGp0bnl2OWtoMnVld2Q5aHN6OW1od2RlbjV0ZTB3ZmprY2N0ZTljdXJ4dmVuOWVlaHFjdHJ2NWhzenJ0aHdkZW41dGUwZGVoaHh0bnZkYWtxcWd5ZGFxeTdjdXJrNDM5eWtwdGt5c3Y3dWRoZGh1NjhzdWNtMjk1YWtxZWZkZWhrZjBkNDk1Y3d1bmw1YWeBgmFuYjE3YWloYjdhOTAxNzZhYQphdWNzYXRhbYF4Imh0dHBzOi8vbm9mZWVzLnRlc3RudXQuY2FzaHUuc3BhY2U="; @@ -536,7 +324,7 @@ mod tests { ); // Test error case - missing required fields - let result = TransportBuilder::default().build(); + let result = crate::nuts::nut18::transport::TransportBuilder::default().build(); assert!(result.is_err()); } @@ -552,7 +340,7 @@ mod tests { ); // Convert to a full Nut10Secret - let full_secret: Nut10Secret = secret_request.clone().into(); + let full_secret: crate::nuts::Nut10Secret = secret_request.clone().into(); // Check conversion assert_eq!(full_secret.kind, Kind::P2PK); @@ -588,4 +376,91 @@ mod tests { assert_eq!(payment_request.nut10, Some(secret_request)); } + + #[test] + fn test_nut10_secret_request_multiple_mints() { + let mint_urls = [ + "https://8333.space:3338", + "https://mint.minibits.cash/Bitcoin", + "https://antifiat.cash", + "https://mint.macadamia.cash", + ] + .iter() + .map(|m| MintUrl::from_str(m).unwrap()) + .collect(); + + let payment_request = PaymentRequestBuilder::default() + .unit(CurrencyUnit::Sat) + .amount(10) + .mints(mint_urls) + .build(); + + let payment_request_str = payment_request.to_string(); + + let r = PaymentRequest::from_str(&payment_request_str).unwrap(); + + assert_eq!(payment_request, r); + } + + #[test] + fn test_nut10_secret_request_htlc() { + let bolt11 = "lnbc100n1p5z3a63pp56854ytysg7e5z9fl3w5mgvrlqjfcytnjv8ff5hm5qt6gl6alxesqdqqcqzzsxqyz5vqsp5p0x0dlhn27s63j4emxnk26p7f94u0lyarnfp5yqmac9gzy4ngdss9qxpqysgqne3v0hnzt2lp0hc69xpzckk0cdcar7glvjhq60lsrfe8gejdm8c564prrnsft6ctxxyrewp4jtezrq3gxxqnfjj0f9tw2qs9y0lslmqpfu7et9"; + + let bolt11 = Bolt11Invoice::from_str(bolt11).unwrap(); + + let nut10 = SpendingConditions::HTLCConditions { + data: bolt11.payment_hash().clone(), + conditions: None, + }; + + let payment_request = PaymentRequestBuilder::default() + .unit(CurrencyUnit::Sat) + .amount(10) + .nut10(nut10.into()) + .build(); + + let payment_request_str = payment_request.to_string(); + + let r = PaymentRequest::from_str(&payment_request_str).unwrap(); + + assert_eq!(payment_request, r); + } + + #[test] + fn test_nut10_secret_request_p2pk() { + // Use a public key for P2PK condition + let pubkey_hex = "026562efcfadc8e86d44da6a8adf80633d974302e62c850774db1fb36ff4cc7198"; + + // Create P2PK spending conditions + let nut10 = SpendingConditions::P2PKConditions { + data: crate::nuts::PublicKey::from_str(pubkey_hex).unwrap(), + conditions: None, + }; + + // Build payment request with P2PK condition + let payment_request = PaymentRequestBuilder::default() + .unit(CurrencyUnit::Sat) + .amount(10) + .payment_id("test-p2pk-id") + .description("P2PK locked payment") + .nut10(nut10.into()) + .build(); + + // Convert to string representation + let payment_request_str = payment_request.to_string(); + + // Parse back from string + let decoded_request = PaymentRequest::from_str(&payment_request_str).unwrap(); + + // Verify round-trip serialization + assert_eq!(payment_request, decoded_request); + + // Verify the P2PK data was preserved correctly + if let Some(nut10_secret) = decoded_request.nut10 { + assert_eq!(nut10_secret.kind, Kind::P2PK); + assert_eq!(nut10_secret.secret_data.data, pubkey_hex); + } else { + panic!("NUT10 secret data missing in decoded payment request"); + } + } } diff --git a/crates/cashu/src/nuts/nut18/secret.rs b/crates/cashu/src/nuts/nut18/secret.rs new file mode 100644 index 00000000..0ae6b604 --- /dev/null +++ b/crates/cashu/src/nuts/nut18/secret.rs @@ -0,0 +1,137 @@ +//! Secret types for NUT-18: Payment Requests + +use std::fmt; + +use serde::de::{self, Deserializer, SeqAccess, Visitor}; +use serde::ser::{SerializeTuple, Serializer}; +use serde::{Deserialize, Serialize}; + +use crate::nuts::nut10::Kind; +use crate::nuts::{Nut10Secret, SpendingConditions}; + +/// Secret Data without nonce for payment requests +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub struct SecretDataRequest { + /// Expresses the spending condition specific to each kind + pub data: String, + /// Additional data committed to and can be used for feature extensions + #[serde(skip_serializing_if = "Option::is_none")] + pub tags: Option>>, +} + +/// Nut10Secret without nonce for payment requests +#[derive(Debug, Clone, Hash, PartialEq, Eq)] +pub struct Nut10SecretRequest { + /// Kind of the spending condition + pub kind: Kind, + /// Secret Data without nonce + pub secret_data: SecretDataRequest, +} + +impl Nut10SecretRequest { + /// Create a new Nut10SecretRequest + pub fn new(kind: Kind, data: S, tags: Option) -> Self + where + S: Into, + V: Into>>, + { + let secret_data = SecretDataRequest { + data: data.into(), + tags: tags.map(|v| v.into()), + }; + + Self { kind, secret_data } + } +} + +impl From for Nut10SecretRequest { + fn from(secret: Nut10Secret) -> Self { + let secret_data = SecretDataRequest { + data: secret.secret_data.data, + tags: secret.secret_data.tags, + }; + + Self { + kind: secret.kind, + secret_data, + } + } +} + +impl From for Nut10Secret { + fn from(value: Nut10SecretRequest) -> Self { + Self::new(value.kind, value.secret_data.data, value.secret_data.tags) + } +} + +impl From for Nut10SecretRequest { + fn from(conditions: SpendingConditions) -> Self { + match conditions { + SpendingConditions::P2PKConditions { data, conditions } => { + Self::new(Kind::P2PK, data.to_hex(), conditions) + } + SpendingConditions::HTLCConditions { data, conditions } => { + Self::new(Kind::HTLC, data.to_string(), conditions) + } + } + } +} + +impl Serialize for Nut10SecretRequest { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + // Create a tuple representing the struct fields + let secret_tuple = (&self.kind, &self.secret_data); + + // Serialize the tuple as a JSON array + let mut s = serializer.serialize_tuple(2)?; + + s.serialize_element(&secret_tuple.0)?; + s.serialize_element(&secret_tuple.1)?; + s.end() + } +} + +// Custom visitor for deserializing Secret +struct SecretVisitor; + +impl<'de> Visitor<'de> for SecretVisitor { + type Value = Nut10SecretRequest; + + fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result { + formatter.write_str("a tuple with two elements: [Kind, SecretData]") + } + + fn visit_seq(self, mut seq: A) -> Result + where + A: SeqAccess<'de>, + { + // Deserialize the kind (first element) + let kind = seq + .next_element()? + .ok_or_else(|| de::Error::invalid_length(0, &self))?; + + // Deserialize the secret_data (second element) + let secret_data = seq + .next_element()? + .ok_or_else(|| de::Error::invalid_length(1, &self))?; + + // Make sure there are no additional elements + if seq.next_element::()?.is_some() { + return Err(de::Error::invalid_length(3, &self)); + } + + Ok(Nut10SecretRequest { kind, secret_data }) + } +} + +impl<'de> Deserialize<'de> for Nut10SecretRequest { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + deserializer.deserialize_seq(SecretVisitor) + } +} diff --git a/crates/cashu/src/nuts/nut18/transport.rs b/crates/cashu/src/nuts/nut18/transport.rs new file mode 100644 index 00000000..6cee2d94 --- /dev/null +++ b/crates/cashu/src/nuts/nut18/transport.rs @@ -0,0 +1,126 @@ +//! Transport types for NUT-18: Payment Requests + +use std::fmt; +use std::str::FromStr; + +use bitcoin::base64::engine::{general_purpose, GeneralPurpose}; +use bitcoin::base64::{alphabet, Engine}; +use serde::{Deserialize, Serialize}; + +use crate::nuts::nut18::error::Error; + +/// Transport Type +#[derive(Debug, Clone, Copy, Hash, PartialEq, Eq, Serialize, Deserialize)] +pub enum TransportType { + /// Nostr + #[serde(rename = "nostr")] + Nostr, + /// Http post + #[serde(rename = "post")] + HttpPost, +} + +impl fmt::Display for TransportType { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + use serde::ser::Error; + let t = serde_json::to_string(self).map_err(|e| fmt::Error::custom(e.to_string()))?; + write!(f, "{t}") + } +} + +impl FromStr for TransportType { + type Err = Error; + + fn from_str(s: &str) -> Result { + match s.to_lowercase().as_str() { + "nostr" => Ok(Self::Nostr), + "post" => Ok(Self::HttpPost), + _ => Err(Error::InvalidPrefix), + } + } +} + +/// Transport +#[derive(Debug, Clone, Hash, PartialEq, Eq, Serialize, Deserialize)] +pub struct Transport { + /// Type + #[serde(rename = "t")] + pub _type: TransportType, + /// Target + #[serde(rename = "a")] + pub target: String, + /// Tags + #[serde(rename = "g")] + pub tags: Option>>, +} + +impl Transport { + /// Create a new TransportBuilder + pub fn builder() -> TransportBuilder { + TransportBuilder::default() + } +} + +impl FromStr for Transport { + type Err = Error; + + fn from_str(s: &str) -> Result { + let decode_config = general_purpose::GeneralPurposeConfig::new() + .with_decode_padding_mode(bitcoin::base64::engine::DecodePaddingMode::Indifferent); + let decoded = GeneralPurpose::new(&alphabet::URL_SAFE, decode_config).decode(s)?; + + Ok(ciborium::from_reader(&decoded[..])?) + } +} + +/// Builder for Transport +#[derive(Debug, Default, Clone)] +pub struct TransportBuilder { + _type: Option, + target: Option, + tags: Option>>, +} + +impl TransportBuilder { + /// Set transport type + pub fn transport_type(mut self, transport_type: TransportType) -> Self { + self._type = Some(transport_type); + self + } + + /// Set target + pub fn target>(mut self, target: S) -> Self { + self.target = Some(target.into()); + self + } + + /// Add a tag + pub fn add_tag(mut self, tag: Vec) -> Self { + self.tags.get_or_insert_with(Vec::new).push(tag); + self + } + + /// Set tags + pub fn tags(mut self, tags: Vec>) -> Self { + self.tags = Some(tags); + self + } + + /// Build the Transport + pub fn build(self) -> Result { + let _type = self._type.ok_or("Transport type is required")?; + let target = self.target.ok_or("Target is required")?; + + Ok(Transport { + _type, + target, + tags: self.tags, + }) + } +} + +impl AsRef for Transport { + fn as_ref(&self) -> &String { + &self.target + } +}