Skip to content

Commit

Permalink
feat(NUT18): Payment request
Browse files Browse the repository at this point in the history
  • Loading branch information
thesimplekid committed Oct 25, 2024
1 parent 4a6b2b2 commit 3cd5f46
Show file tree
Hide file tree
Showing 3 changed files with 167 additions and 0 deletions.
3 changes: 3 additions & 0 deletions crates/cdk/src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)]
Expand Down
1 change: 1 addition & 0 deletions crates/cdk/src/nuts/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
163 changes: 163 additions & 0 deletions crates/cdk/src/nuts/nut18.rs
Original file line number Diff line number Diff line change
@@ -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(())
}
}

0 comments on commit 3cd5f46

Please sign in to comment.