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