mirror of
https://github.com/aljazceru/cdk.git
synced 2026-02-09 15:16:00 +01:00
fix: nut18 payment request encoding/decoding (#758)
* fix: nut18 payment request encoding/decoding * refactor: reorder nut18fns * refactor: reorder nut18
This commit is contained in:
@@ -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
crates/cashu/src/nuts/nut18/error.rs
Normal file
17
crates/cashu/src/nuts/nut18/error.rs
Normal file
@@ -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
crates/cashu/src/nuts/nut18/mod.rs
Normal file
11
crates/cashu/src/nuts/nut18/mod.rs
Normal file
@@ -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};
|
||||
@@ -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
crates/cashu/src/nuts/nut18/secret.rs
Normal file
137
crates/cashu/src/nuts/nut18/secret.rs
Normal file
@@ -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
crates/cashu/src/nuts/nut18/transport.rs
Normal file
126
crates/cashu/src/nuts/nut18/transport.rs
Normal file
@@ -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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user