ソースを参照

fix: nut18 payment request encoding/decoding (#758)

* fix: nut18 payment request encoding/decoding

* refactor: reorder nut18fns

* refactor: reorder nut18
thesimplekid 1 ヶ月 前
コミット
3920c6f9bc

+ 111 - 1
crates/cashu/src/nuts/nut10.rs

@@ -2,8 +2,10 @@
 //!
 //! <https://github.com/cashubtc/nuts/blob/main/10.md>
 
+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<Secret> 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<A>(self, mut seq: A) -> Result<Self::Value, A::Error>
+    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::<serde::de::IgnoredAny>()?.is_some() {
+            return Err(de::Error::invalid_length(3, &self));
+        }
+
+        Ok(Secret { kind, secret_data })
+    }
+}
+
+impl<'de> Deserialize<'de> for Secret {
+    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
+    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);
+    }
 }

+ 17 - 0
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<std::io::Error>),
+    /// Base64 error
+    #[error(transparent)]
+    Base64Error(#[from] bitcoin::base64::DecodeError),
+}

+ 11 - 0
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};

+ 128 - 253
crates/cashu/src/nuts/nut18.rs → 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<std::io::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<Self, Self::Err> {
-        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<Self, Self::Err> {
-        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<Vec<Vec<String>>>,
-}
-
-impl Transport {
-    /// Create a new TransportBuilder
-    pub fn builder() -> TransportBuilder {
-        TransportBuilder::default()
-    }
-}
-
-/// Builder for Transport
-#[derive(Debug, Default, Clone)]
-pub struct TransportBuilder {
-    _type: Option<TransportType>,
-    target: Option<String>,
-    tags: Option<Vec<Vec<String>>>,
-}
-
-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<S: Into<String>>(mut self, target: S) -> Self {
-        self.target = Some(target.into());
-        self
-    }
-
-    /// Add a tag
-    pub fn add_tag(mut self, tag: Vec<String>) -> Self {
-        self.tags.get_or_insert_with(Vec::new).push(tag);
-        self
-    }
-
-    /// Set tags
-    pub fn tags(mut self, tags: Vec<Vec<String>>) -> Self {
-        self.tags = Some(tags);
-        self
-    }
-
-    /// Build the Transport
-    pub fn build(self) -> Result<Transport, &'static str> {
-        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<String> 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<Vec<Vec<String>>>,
-}
-
-/// 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<S, V>(kind: Kind, data: S, tags: Option<V>) -> Self
-    where
-        S: Into<String>,
-        V: Into<Vec<Vec<String>>>,
-    {
-        let secret_data = SecretDataRequest {
-            data: data.into(),
-            tags: tags.map(|v| v.into()),
-        };
-
-        Self { kind, secret_data }
-    }
-}
-
-impl From<Nut10Secret> 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<Nut10SecretRequest> for Nut10Secret {
-    fn from(value: Nut10SecretRequest) -> Self {
-        Self::new(value.kind, value.secret_data.data, value.secret_data.tags)
-    }
-}
-
-impl From<SpendingConditions> 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<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
-    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<Option<String>> for PaymentRequest {
+    fn as_ref(&self) -> &Option<String> {
+        &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<Self, Self::Err> {
+        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<Option<String>> for PaymentRequest {
-    fn as_ref(&self) -> &Option<String> {
-        &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<Self, Self::Err> {
-        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");
+        }
+    }
 }

+ 137 - 0
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<Vec<Vec<String>>>,
+}
+
+/// 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<S, V>(kind: Kind, data: S, tags: Option<V>) -> Self
+    where
+        S: Into<String>,
+        V: Into<Vec<Vec<String>>>,
+    {
+        let secret_data = SecretDataRequest {
+            data: data.into(),
+            tags: tags.map(|v| v.into()),
+        };
+
+        Self { kind, secret_data }
+    }
+}
+
+impl From<Nut10Secret> 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<Nut10SecretRequest> for Nut10Secret {
+    fn from(value: Nut10SecretRequest) -> Self {
+        Self::new(value.kind, value.secret_data.data, value.secret_data.tags)
+    }
+}
+
+impl From<SpendingConditions> 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<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
+    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<A>(self, mut seq: A) -> Result<Self::Value, A::Error>
+    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::<serde::de::IgnoredAny>()?.is_some() {
+            return Err(de::Error::invalid_length(3, &self));
+        }
+
+        Ok(Nut10SecretRequest { kind, secret_data })
+    }
+}
+
+impl<'de> Deserialize<'de> for Nut10SecretRequest {
+    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
+    where
+        D: Deserializer<'de>,
+    {
+        deserializer.deserialize_seq(SecretVisitor)
+    }
+}

+ 126 - 0
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<Self, Self::Err> {
+        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<Vec<Vec<String>>>,
+}
+
+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<Self, Self::Err> {
+        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<TransportType>,
+    target: Option<String>,
+    tags: Option<Vec<Vec<String>>>,
+}
+
+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<S: Into<String>>(mut self, target: S) -> Self {
+        self.target = Some(target.into());
+        self
+    }
+
+    /// Add a tag
+    pub fn add_tag(mut self, tag: Vec<String>) -> Self {
+        self.tags.get_or_insert_with(Vec::new).push(tag);
+        self
+    }
+
+    /// Set tags
+    pub fn tags(mut self, tags: Vec<Vec<String>>) -> Self {
+        self.tags = Some(tags);
+        self
+    }
+
+    /// Build the Transport
+    pub fn build(self) -> Result<Transport, &'static str> {
+        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<String> for Transport {
+    fn as_ref(&self) -> &String {
+        &self.target
+    }
+}