Эх сурвалжийг харах

refactor Nut10SecretRequest and add tests with provided test vectors (#900)

* refactor Nut10SecretRequest and add tests with provided test vectors
lollerfirst 2 сар өмнө
parent
commit
d07388d1ce

+ 1 - 1
crates/cashu/src/nuts/nut18/mod.rs

@@ -7,5 +7,5 @@ pub mod transport;
 
 pub use error::Error;
 pub use payment_request::{PaymentRequest, PaymentRequestBuilder, PaymentRequestPayload};
-pub use secret::{Nut10SecretRequest, SecretDataRequest};
+pub use secret::Nut10SecretRequest;
 pub use transport::{Transport, TransportBuilder, TransportType};

+ 226 - 9
crates/cashu/src/nuts/nut18/payment_request.rs

@@ -43,6 +43,7 @@ pub struct PaymentRequest {
     #[serde(skip_serializing_if = "Option::is_none")]
     pub transports: Option<Vec<Transport>>,
     /// Nut10
+    #[serde(skip_serializing_if = "Option::is_none")]
     pub nut10: Option<Nut10SecretRequest>,
 }
 
@@ -358,14 +359,8 @@ mod tests {
 
         // Check round-trip conversion
         assert_eq!(converted_back.kind, secret_request.kind);
-        assert_eq!(
-            converted_back.secret_data.data,
-            secret_request.secret_data.data
-        );
-        assert_eq!(
-            converted_back.secret_data.tags,
-            secret_request.secret_data.tags
-        );
+        assert_eq!(converted_back.data, secret_request.data);
+        assert_eq!(converted_back.tags, secret_request.tags);
 
         // Test in PaymentRequest builder
         let payment_request = PaymentRequest::builder()
@@ -458,9 +453,231 @@ mod tests {
         // 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);
+            assert_eq!(nut10_secret.data, pubkey_hex);
         } else {
             panic!("NUT10 secret data missing in decoded payment request");
         }
     }
+
+    /// Test vectors from NUT-18 specification
+    /// https://github.com/cashubtc/nuts/blob/main/tests/18-tests.md
+
+    #[test]
+    fn test_basic_payment_request() {
+        // Basic payment request with required fields
+        let json = r#"{
+            "i": "b7a90176",
+            "a": 10,
+            "u": "sat",
+            "m": ["https://8333.space:3338"],
+            "t": [
+                {
+                    "t": "nostr",
+                    "a": "nprofile1qy28wumn8ghj7un9d3shjtnyv9kh2uewd9hsz9mhwden5te0wfjkccte9curxven9eehqctrv5hszrthwden5te0dehhxtnvdakqqgydaqy7curk439ykptkysv7udhdhu68sucm295akqefdehkf0d495cwunl5",
+                    "g": [["n", "17"]]
+                }
+            ]
+        }"#;
+
+        let expected_encoded = "creqApWF0gaNhdGVub3N0cmFheKlucHJvZmlsZTFxeTI4d3VtbjhnaGo3dW45ZDNzaGp0bnl2OWtoMnVld2Q5aHN6OW1od2RlbjV0ZTB3ZmprY2N0ZTljdXJ4dmVuOWVlaHFjdHJ2NWhzenJ0aHdkZW41dGUwZGVoaHh0bnZkYWtxcWd5ZGFxeTdjdXJrNDM5eWtwdGt5c3Y3dWRoZGh1NjhzdWNtMjk1YWtxZWZkZWhrZjBkNDk1Y3d1bmw1YWeBgmFuYjE3YWloYjdhOTAxNzZhYQphdWNzYXRhbYF3aHR0cHM6Ly84MzMzLnNwYWNlOjMzMzg=";
+
+        // Parse the JSON into a PaymentRequest
+        let payment_request: PaymentRequest = serde_json::from_str(json).unwrap();
+        let payment_request_cloned = payment_request.clone();
+
+        // Verify the payment request fields
+        assert_eq!(
+            payment_request_cloned.payment_id.as_ref().unwrap(),
+            "b7a90176"
+        );
+        assert_eq!(payment_request_cloned.amount.unwrap(), Amount::from(10));
+        assert_eq!(payment_request_cloned.unit.unwrap(), CurrencyUnit::Sat);
+        assert_eq!(
+            payment_request_cloned.mints.unwrap(),
+            vec![MintUrl::from_str("https://8333.space:3338").unwrap()]
+        );
+
+        let transport = payment_request.transports.as_ref().unwrap();
+        let transport = transport.first().unwrap();
+        assert_eq!(transport._type, TransportType::Nostr);
+        assert_eq!(transport.target, "nprofile1qy28wumn8ghj7un9d3shjtnyv9kh2uewd9hsz9mhwden5te0wfjkccte9curxven9eehqctrv5hszrthwden5te0dehhxtnvdakqqgydaqy7curk439ykptkysv7udhdhu68sucm295akqefdehkf0d495cwunl5");
+        assert_eq!(
+            transport.tags,
+            Some(vec![vec!["n".to_string(), "17".to_string()]])
+        );
+
+        // Test encoding - the encoded form should match the expected output
+        let encoded = payment_request.to_string();
+
+        // For now, let's verify it can be decoded back correctly
+        let decoded = PaymentRequest::from_str(&encoded).unwrap();
+        assert_eq!(payment_request, decoded);
+
+        // Test decoding the expected encoded string
+        let decoded_from_spec = PaymentRequest::from_str(expected_encoded).unwrap();
+        assert_eq!(decoded_from_spec.payment_id.as_ref().unwrap(), "b7a90176");
+        assert_eq!(decoded_from_spec.amount.unwrap(), Amount::from(10));
+        assert_eq!(decoded_from_spec.unit.unwrap(), CurrencyUnit::Sat);
+        assert_eq!(
+            decoded_from_spec.mints.unwrap(),
+            vec![MintUrl::from_str("https://8333.space:3338").unwrap()]
+        );
+    }
+
+    #[test]
+    fn test_nostr_transport_payment_request() {
+        // Nostr transport payment request with multiple mints
+        let json = r#"{
+            "i": "f92a51b8",
+            "a": 100,
+            "u": "sat",
+            "m": ["https://mint1.example.com", "https://mint2.example.com"],
+            "t": [
+                {
+                    "t": "nostr",
+                    "a": "npub1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq28spj3",
+                    "g": [["n", "17"], ["n", "9735"]]
+                }
+            ]
+        }"#;
+
+        let expected_encoded = "creqApWF0gaNhdGVub3N0cmFheD9ucHViMXFxcXFxcXFxcXFxcXFxcXFxcXFxcXFxcXFxcXFxcXFxcXFxcXFxcXFxcXFxcXFxcXFxcXEyOHNwajNhZ4KCYW5iMTeCYW5kOTczNWFpaGY5MmE1MWI4YWEYZGF1Y3NhdGFtgngZaHR0cHM6Ly9taW50MS5leGFtcGxlLmNvbXgZaHR0cHM6Ly9taW50Mi5leGFtcGxlLmNvbQ==";
+
+        // Parse the JSON into a PaymentRequest
+        let payment_request: PaymentRequest = serde_json::from_str(json).unwrap();
+        let payment_request_cloned = payment_request.clone();
+
+        // Verify the payment request fields
+        assert_eq!(
+            payment_request_cloned.payment_id.as_ref().unwrap(),
+            "f92a51b8"
+        );
+        assert_eq!(payment_request_cloned.amount.unwrap(), Amount::from(100));
+        assert_eq!(payment_request_cloned.unit.unwrap(), CurrencyUnit::Sat);
+        assert_eq!(
+            payment_request_cloned.mints.unwrap(),
+            vec![
+                MintUrl::from_str("https://mint1.example.com").unwrap(),
+                MintUrl::from_str("https://mint2.example.com").unwrap()
+            ]
+        );
+
+        let transport = payment_request_cloned.transports.unwrap();
+        let transport = transport.first().unwrap();
+        assert_eq!(transport._type, TransportType::Nostr);
+        assert_eq!(
+            transport.target,
+            "npub1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq28spj3"
+        );
+        assert_eq!(
+            transport.tags,
+            Some(vec![
+                vec!["n".to_string(), "17".to_string()],
+                vec!["n".to_string(), "9735".to_string()]
+            ])
+        );
+
+        // Test round-trip serialization
+        let encoded = payment_request.to_string();
+        let decoded = PaymentRequest::from_str(&encoded).unwrap();
+        assert_eq!(payment_request, decoded);
+
+        // Test decoding the expected encoded string
+        let decoded_from_spec = PaymentRequest::from_str(expected_encoded).unwrap();
+        assert_eq!(decoded_from_spec.payment_id.as_ref().unwrap(), "f92a51b8");
+    }
+
+    #[test]
+    fn test_minimal_payment_request() {
+        // Minimal payment request with only required fields
+        let json = r#"{
+            "i": "7f4a2b39",
+            "u": "sat",
+            "m": ["https://mint.example.com"]
+        }"#;
+
+        let expected_encoded =
+            "creqAo2FpaDdmNGEyYjM5YXVjc2F0YW2BeBhodHRwczovL21pbnQuZXhhbXBsZS5jb20=";
+
+        // Parse the JSON into a PaymentRequest
+        let payment_request: PaymentRequest = serde_json::from_str(json).unwrap();
+        let payment_request_cloned = payment_request.clone();
+
+        // Verify the payment request fields
+        assert_eq!(
+            payment_request_cloned.payment_id.as_ref().unwrap(),
+            "7f4a2b39"
+        );
+        assert_eq!(payment_request_cloned.amount, None);
+        assert_eq!(payment_request_cloned.unit.unwrap(), CurrencyUnit::Sat);
+        assert_eq!(
+            payment_request_cloned.mints.unwrap(),
+            vec![MintUrl::from_str("https://mint.example.com").unwrap()]
+        );
+        assert_eq!(payment_request_cloned.transports, None);
+
+        // Test round-trip serialization
+        let encoded = payment_request.to_string();
+        let decoded = PaymentRequest::from_str(&encoded).unwrap();
+        assert_eq!(payment_request, decoded);
+
+        // Test decoding the expected encoded string
+        let decoded_from_spec = PaymentRequest::from_str(expected_encoded).unwrap();
+        assert_eq!(decoded_from_spec.payment_id.as_ref().unwrap(), "7f4a2b39");
+    }
+
+    #[test]
+    fn test_nut10_locking_payment_request() {
+        // Payment request with NUT-10 P2PK locking
+        let json = r#"{
+            "i": "c9e45d2a",
+            "a": 500,
+            "u": "sat",
+            "m": ["https://mint.example.com"],
+            "nut10": {
+                "k": "P2PK",
+                "d": "02c3b5bb27e361457c92d93d78dd73d3d53732110b2cfe8b50fbc0abc615e9c331",
+                "t": [["timeout", "3600"]]
+            }
+        }"#;
+
+        let expected_encoded = "creqApWFpaGM5ZTQ1ZDJhYWEZAfRhdWNzYXRhbYF4GGh0dHBzOi8vbWludC5leGFtcGxlLmNvbWVudXQxMKNha2RQMlBLYWR4QjAyYzNiNWJiMjdlMzYxNDU3YzkyZDkzZDc4ZGQ3M2QzZDUzNzMyMTEwYjJjZmU4YjUwZmJjMGFiYzYxNWU5YzMzMWF0gYJndGltZW91dGQzNjAw";
+
+        // Parse the JSON into a PaymentRequest
+        let payment_request: PaymentRequest = serde_json::from_str(json).unwrap();
+        let payment_request_cloned = payment_request.clone();
+
+        // Verify the payment request fields
+        assert_eq!(
+            payment_request_cloned.payment_id.as_ref().unwrap(),
+            "c9e45d2a"
+        );
+        assert_eq!(payment_request_cloned.amount.unwrap(), Amount::from(500));
+        assert_eq!(payment_request_cloned.unit.unwrap(), CurrencyUnit::Sat);
+        assert_eq!(
+            payment_request_cloned.mints.unwrap(),
+            vec![MintUrl::from_str("https://mint.example.com").unwrap()]
+        );
+
+        // Test NUT-10 locking
+        let nut10 = payment_request_cloned.nut10.unwrap();
+        assert_eq!(nut10.kind, Kind::P2PK);
+        assert_eq!(
+            nut10.data,
+            "02c3b5bb27e361457c92d93d78dd73d3d53732110b2cfe8b50fbc0abc615e9c331"
+        );
+        assert_eq!(
+            nut10.tags,
+            Some(vec![vec!["timeout".to_string(), "3600".to_string()]])
+        );
+
+        // Test round-trip serialization
+        let encoded = payment_request.to_string();
+        let decoded = PaymentRequest::from_str(&encoded).unwrap();
+        assert_eq!(payment_request, decoded);
+
+        // Test decoding the expected encoded string
+        let decoded_from_spec = PaymentRequest::from_str(expected_encoded).unwrap();
+        assert_eq!(decoded_from_spec.payment_id.as_ref().unwrap(), "c9e45d2a");
+    }
 }

+ 65 - 74
crates/cashu/src/nuts/nut18/secret.rs

@@ -1,31 +1,21 @@
 //! 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)]
+#[derive(Debug, Clone, Hash, PartialEq, Eq, Serialize, Deserialize)]
 pub struct Nut10SecretRequest {
     /// Kind of the spending condition
+    #[serde(rename = "k")]
     pub kind: Kind,
-    /// Secret Data without nonce
-    pub secret_data: SecretDataRequest,
+    /// Secret data
+    #[serde(rename = "d")]
+    pub data: String,
+    /// Additional data committed to and can be used for feature extensions
+    #[serde(rename = "t", skip_serializing_if = "Option::is_none")]
+    pub tags: Option<Vec<Vec<String>>>,
 }
 
 impl Nut10SecretRequest {
@@ -35,32 +25,27 @@ impl Nut10SecretRequest {
         S: Into<String>,
         V: Into<Vec<Vec<String>>>,
     {
-        let secret_data = SecretDataRequest {
+        Self {
+            kind,
             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().to_string(),
-            tags: secret.secret_data().tags().cloned(),
-        };
-
         Self {
             kind: secret.kind(),
-            secret_data,
+            data: secret.secret_data().data().to_string(),
+            tags: secret.secret_data().tags().cloned(),
         }
     }
 }
 
 impl From<Nut10SecretRequest> for Nut10Secret {
     fn from(value: Nut10SecretRequest) -> Self {
-        Self::new(value.kind, value.secret_data.data, value.secret_data.tags)
+        Self::new(value.kind, value.data, value.tags)
     }
 }
 
@@ -77,61 +62,67 @@ impl From<SpendingConditions> for Nut10SecretRequest {
     }
 }
 
-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);
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    #[test]
+    fn test_nut10_secret_request_serialization() {
+        let request = Nut10SecretRequest::new(
+            Kind::P2PK,
+            "026562efcfadc8e86d44da6a8adf80633d974302e62c850774db1fb36ff4cc7198",
+            Some(vec![vec!["key".to_string(), "value".to_string()]]),
+        );
 
-        // Serialize the tuple as a JSON array
-        let mut s = serializer.serialize_tuple(2)?;
+        let json = serde_json::to_string(&request).unwrap();
 
-        s.serialize_element(&secret_tuple.0)?;
-        s.serialize_element(&secret_tuple.1)?;
-        s.end()
+        // Verify json has abbreviated field names
+        assert!(json.contains(r#""k":"P2PK""#));
+        assert!(json.contains(r#""d":"026562"#));
+        assert!(json.contains(r#""t":[["key","#));
     }
-}
 
-// Custom visitor for deserializing Secret
-struct SecretVisitor;
+    #[test]
+    fn test_roundtrip_serialization() {
+        let original = Nut10SecretRequest {
+            kind: Kind::P2PK,
+            data: "test_data".into(),
+            tags: Some(vec![vec!["key".to_string(), "value".to_string()]]),
+        };
 
-impl<'de> Visitor<'de> for SecretVisitor {
-    type Value = Nut10SecretRequest;
+        let json = serde_json::to_string(&original).unwrap();
+        let decoded: Nut10SecretRequest = serde_json::from_str(&json).unwrap();
 
-    fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
-        formatter.write_str("a tuple with two elements: [Kind, SecretData]")
+        assert_eq!(original, decoded);
     }
 
-    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));
-        }
+    #[test]
+    fn test_from_nut10_secret() {
+        let secret = Nut10Secret::new(
+            Kind::P2PK,
+            "test_data",
+            Some(vec![vec!["key".to_string(), "value".to_string()]]),
+        );
+
+        let request: Nut10SecretRequest = secret.clone().into();
 
-        Ok(Nut10SecretRequest { kind, secret_data })
+        assert_eq!(request.kind, secret.kind());
+        assert_eq!(request.data, secret.secret_data().data());
+        assert_eq!(request.tags, secret.secret_data().tags().cloned());
     }
-}
 
-impl<'de> Deserialize<'de> for Nut10SecretRequest {
-    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
-    where
-        D: Deserializer<'de>,
-    {
-        deserializer.deserialize_seq(SecretVisitor)
+    #[test]
+    fn test_into_nut10_secret() {
+        let request = Nut10SecretRequest {
+            kind: Kind::HTLC,
+            data: "test_hash".into(),
+            tags: None,
+        };
+
+        let secret: Nut10Secret = request.clone().into();
+
+        assert_eq!(secret.kind(), request.kind);
+        assert_eq!(secret.secret_data().data(), request.data);
+        assert_eq!(secret.secret_data().tags(), request.tags.as_ref());
     }
 }