Bladeren bron

feat(NUT18): Payment request

thesimplekid 4 maanden geleden
bovenliggende
commit
3cd5f463d7
3 gewijzigde bestanden met toevoegingen van 167 en 0 verwijderingen
  1. 3 0
      crates/cdk/src/error.rs
  2. 1 0
      crates/cdk/src/nuts/mod.rs
  3. 163 0
      crates/cdk/src/nuts/nut18.rs

+ 3 - 0
crates/cdk/src/error.rs

@@ -234,6 +234,9 @@ pub enum Error {
     /// NUT14 Error
     #[error(transparent)]
     NUT14(#[from] crate::nuts::nut14::Error),
+    /// NUT18 Error
+    #[error(transparent)]
+    NUT18(#[from] crate::nuts::nut18::Error),
     /// Database Error
     #[cfg(any(feature = "wallet", feature = "mint"))]
     #[error(transparent)]

+ 1 - 0
crates/cdk/src/nuts/mod.rs

@@ -18,6 +18,7 @@ pub mod nut12;
 pub mod nut13;
 pub mod nut14;
 pub mod nut15;
+pub mod nut18;
 
 pub use nut00::{
     BlindSignature, BlindedMessage, CurrencyUnit, PaymentMethod, PreMint, PreMintSecrets, Proof,

+ 163 - 0
crates/cdk/src/nuts/nut18.rs

@@ -0,0 +1,163 @@
+//! NUT-18: Payment Requests
+//!
+//! <https://github.com/cashubtc/nuts/blob/main/18.md>
+
+use std::{fmt, str::FromStr};
+
+use bitcoin::base64::{
+    alphabet,
+    engine::{general_purpose, GeneralPurpose},
+    Engine,
+};
+use serde::{Deserialize, Serialize};
+use thiserror::Error;
+
+use crate::{mint_url::MintUrl, Amount};
+
+use super::CurrencyUnit;
+
+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
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+pub struct Transport {
+    /// Type
+    #[serde(rename = "t")]
+    pub _type: String,
+    /// Target
+    #[serde(rename = "a")]
+    pub target: String,
+    /// Tags
+    #[serde(rename = "g")]
+    pub tags: Option<Vec<Vec<String>>>,
+}
+
+/// Payment Request
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+pub struct PaymentRequest {
+    /// `Payment id`
+    #[serde(rename = "i")]
+    pub payment_id: Option<String>,
+    /// Amount
+    #[serde(rename = "a")]
+    pub amount: Option<Amount>,
+    /// Unit
+    #[serde(rename = "u")]
+    pub unit: Option<CurrencyUnit>,
+    /// Single use
+    #[serde(rename = "s")]
+    pub single_use: Option<bool>,
+    /// Mints
+    #[serde(rename = "m")]
+    pub mints: Option<Vec<MintUrl>>,
+    /// Description
+    #[serde(rename = "d")]
+    pub description: Option<String>,
+    /// Transport
+    #[serde(rename = "t")]
+    pub transports: Vec<Transport>,
+}
+
+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[..])?)
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use std::str::FromStr;
+
+    use super::*;
+
+    const PAYMENT_REQUEST: &str = "creqApWF0gaNhdGVub3N0cmFheKlucHJvZmlsZTFxeTI4d3VtbjhnaGo3dW45ZDNzaGp0bnl2OWtoMnVld2Q5aHN6OW1od2RlbjV0ZTB3ZmprY2N0ZTljdXJ4dmVuOWVlaHFjdHJ2NWhzenJ0aHdkZW41dGUwZGVoaHh0bnZkYWtxcWd5ZGFxeTdjdXJrNDM5eWtwdGt5c3Y3dWRoZGh1NjhzdWNtMjk1YWtxZWZkZWhrZjBkNDk1Y3d1bmw1YWeBgmFuYjE3YWloYjdhOTAxNzZhYQphdWNzYXRhbYF4Imh0dHBzOi8vbm9mZWVzLnRlc3RudXQuY2FzaHUuc3BhY2U=";
+
+    #[test]
+    fn test_decode_payment_req() -> anyhow::Result<()> {
+        let req = PaymentRequest::from_str(PAYMENT_REQUEST)?;
+
+        assert_eq!(&req.payment_id.unwrap(), "b7a90176");
+        assert_eq!(req.amount.unwrap(), 10.into());
+        assert_eq!(req.unit.unwrap(), CurrencyUnit::Sat);
+        assert_eq!(
+            req.mints.unwrap(),
+            vec![MintUrl::from_str("https://nofees.testnut.cashu.space")?]
+        );
+        assert_eq!(req.unit.unwrap(), CurrencyUnit::Sat);
+
+        let transport = req.transports.first().unwrap();
+
+        let expected_transport = Transport {_type: "nostr".to_string(), target: "nprofile1qy28wumn8ghj7un9d3shjtnyv9kh2uewd9hsz9mhwden5te0wfjkccte9curxven9eehqctrv5hszrthwden5te0dehhxtnvdakqqgydaqy7curk439ykptkysv7udhdhu68sucm295akqefdehkf0d495cwunl5".to_string(), tags: Some(vec![vec!["n".to_string(), "17".to_string()]])};
+
+        assert_eq!(transport, &expected_transport);
+
+        Ok(())
+    }
+
+    #[test]
+    fn test_roundtrip_payment_req() -> anyhow::Result<()> {
+        let transport = Transport {_type: "nostr".to_string(), target: "nprofile1qy28wumn8ghj7un9d3shjtnyv9kh2uewd9hsz9mhwden5te0wfjkccte9curxven9eehqctrv5hszrthwden5te0dehhxtnvdakqqgydaqy7curk439ykptkysv7udhdhu68sucm295akqefdehkf0d495cwunl5".to_string(), tags: Some(vec![vec!["n".to_string(), "17".to_string()]])};
+
+        let request = PaymentRequest {
+            payment_id: Some("b7a90176".to_string()),
+            amount: Some(10.into()),
+            unit: Some(CurrencyUnit::Sat),
+            single_use: None,
+            mints: Some(vec!["https://nofees.testnut.cashu.space".parse()?]),
+            description: None,
+            transports: vec![transport.clone()],
+        };
+
+        let request_str = request.to_string();
+
+        let req = PaymentRequest::from_str(&request_str)?;
+
+        assert_eq!(&req.payment_id.unwrap(), "b7a90176");
+        assert_eq!(req.amount.unwrap(), 10.into());
+        assert_eq!(req.unit.unwrap(), CurrencyUnit::Sat);
+        assert_eq!(
+            req.mints.unwrap(),
+            vec![MintUrl::from_str("https://nofees.testnut.cashu.space")?]
+        );
+        assert_eq!(req.unit.unwrap(), CurrencyUnit::Sat);
+
+        let t = req.transports.first().unwrap();
+        assert_eq!(&transport, t);
+
+        Ok(())
+    }
+}