From 4597d1d31c77588c76d9fb5b630bf23ea0a9dbf7 Mon Sep 17 00:00:00 2001 From: thesimplekid Date: Tue, 22 Oct 2024 11:15:01 +0100 Subject: [PATCH] feat(NUT18): Payment request --- crates/cdk/src/nuts/mod.rs | 1 + crates/cdk/src/nuts/nut18.rs | 163 +++++++++++++++++++++++++++++++++++ 2 files changed, 164 insertions(+) create mode 100644 crates/cdk/src/nuts/nut18.rs diff --git a/crates/cdk/src/nuts/mod.rs b/crates/cdk/src/nuts/mod.rs index c710ac030..12d317df9 100644 --- a/crates/cdk/src/nuts/mod.rs +++ b/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, diff --git a/crates/cdk/src/nuts/nut18.rs b/crates/cdk/src/nuts/nut18.rs new file mode 100644 index 000000000..2c574d153 --- /dev/null +++ b/crates/cdk/src/nuts/nut18.rs @@ -0,0 +1,163 @@ +//! NUT-18: Payment Requests +//! +//! + +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), + /// 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>>, +} + +/// Payment Request +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct PaymentRequest { + /// `Payment id` + #[serde(rename = "i")] + pub payment_id: Option, + /// Amount + #[serde(rename = "a")] + pub amount: Option, + /// Unit + #[serde(rename = "u")] + pub unit: Option, + /// Single use + #[serde(rename = "s")] + pub single_use: Option, + /// Mints + #[serde(rename = "m")] + pub mints: Option>, + /// Description + #[serde(rename = "d")] + pub description: Option, + /// Transport + #[serde(rename = "t")] + pub transports: Vec, +} + +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 { + 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(()) + } +}