-
Notifications
You must be signed in to change notification settings - Fork 47
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- Loading branch information
1 parent
4a6b2b2
commit 3cd5f46
Showing
3 changed files
with
167 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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(()) | ||
} | ||
} |