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

* refactor Nut10SecretRequest and add tests with provided test vectors
This commit is contained in:
lollerfirst
2025-07-23 15:55:27 +02:00
committed by GitHub
parent 49a05f410d
commit d07388d1ce
3 changed files with 301 additions and 93 deletions

View File

@@ -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};

View File

@@ -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");
}
}

View File

@@ -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::*;
// Serialize the tuple as a JSON array
let mut s = serializer.serialize_tuple(2)?;
#[test]
fn test_nut10_secret_request_serialization() {
let request = Nut10SecretRequest::new(
Kind::P2PK,
"026562efcfadc8e86d44da6a8adf80633d974302e62c850774db1fb36ff4cc7198",
Some(vec![vec!["key".to_string(), "value".to_string()]]),
);
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)
let json = serde_json::to_string(&request).unwrap();
// Verify json has abbreviated field names
assert!(json.contains(r#""k":"P2PK""#));
assert!(json.contains(r#""d":"026562"#));
assert!(json.contains(r#""t":[["key","#));
}
#[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()]]),
};
let json = serde_json::to_string(&original).unwrap();
let decoded: Nut10SecretRequest = serde_json::from_str(&json).unwrap();
assert_eq!(original, decoded);
}
#[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();
assert_eq!(request.kind, secret.kind());
assert_eq!(request.data, secret.secret_data().data());
assert_eq!(request.tags, secret.secret_data().tags().cloned());
}
#[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());
}
}