mod query_parameters;
use solana_program::pubkey::{ParsePubkeyError, Pubkey};
use std::collections::HashSet;
use std::str::FromStr;
use std::{convert::TryFrom, num::ParseFloatError};
use thiserror::Error;
use query_parameters::QueryParameter;
use url::{ParseError, Url};
const SOLANA_PAY_SCHEME: &str = "solana";
#[derive(Error, Debug)]
pub enum SolanaPayError {
#[error("Could not parse number {0}")]
ParseFloatError(#[from] ParseFloatError),
#[error("Could not parse Pubkey {0}")]
ParsePubkeyError(#[from] ParsePubkeyError),
#[error("Must define a postive number for `amount`")]
NegativeAmount,
#[error("Must define a postive number for `amount` not {0}")]
UnknownAmountType(String),
#[error("Url length is invalid: `{0}`")]
UrlInvalidLength(usize),
#[error("{0} cannot be defined twice in the URL")]
DefinedTwice(String),
#[error("Cannot parse URL: Err({0})")]
InvalidUrl(#[from] ParseError),
#[error("Invalid URL key: {0} with value: {1}")]
InvalidUrlKey(String, String),
#[error("Invalid URL key: {0}")]
InvalidKey(String),
#[error("Invalid scheme {0}")]
IncorrectScheme(String),
}
#[derive(Default, PartialEq, Debug, Clone)]
pub struct SolanaPayRequest {
pub recipient: Pubkey,
pub amount: Option<f64>,
pub spl_token: Option<Pubkey>,
pub references: Vec<Pubkey>,
pub label: Option<String>,
pub message: Option<String>,
pub memo: Option<String>,
pub extensions: Vec<(String, String)>,
}
fn tokenize_params(url: Url) -> Result<Vec<(QueryParameter, String)>, SolanaPayError> {
let mut params: Vec<(QueryParameter, String)> = vec![];
let mut seen_set: HashSet<QueryParameter> = HashSet::new();
for (key, value) in url.query_pairs() {
let param = QueryParameter::from(key.as_ref());
if !param.allows_multiple() && seen_set.contains(¶m) {
return Err(SolanaPayError::DefinedTwice(key.to_string()));
} else {
seen_set.insert(param.clone());
params.push((param, value.to_string()));
}
}
Ok(params)
}
impl IntoIterator for SolanaPayRequest {
type Item = (String, String);
type IntoIter = std::vec::IntoIter<Self::Item>;
fn into_iter(self) -> Self::IntoIter {
use query_parameters::QueryParameter as QP;
std::iter::empty()
.chain(
self.amount
.map(|field| (QP::Amount.to_string(), field.to_string())),
)
.chain(
self.spl_token
.map(|field| (QP::SplToken.to_string(), field.to_string())),
)
.chain(
self.references
.into_iter()
.map(|v| (QP::Reference.to_string(), v.to_string())),
)
.chain(self.label.map(|field| (QP::Label.to_string(), field)))
.chain(self.message.map(|field| (QP::Message.to_string(), field)))
.chain(self.memo.map(|field| (QP::Memo.to_string(), field)))
.chain(self.extensions)
.collect::<Vec<_>>()
.into_iter()
}
}
impl SolanaPayRequest {
fn append_value(&mut self, key: &QueryParameter, value: &str) -> Result<(), SolanaPayError> {
match key {
QueryParameter::Amount => {
let amount = value.parse::<f64>()?;
if amount < 0.0 {
return Err(SolanaPayError::NegativeAmount);
}
self.amount = Some(amount);
}
QueryParameter::SplToken => self.spl_token = Some(Pubkey::from_str(value)?),
QueryParameter::Reference => self.references.push(Pubkey::from_str(value)?),
QueryParameter::Label => self.label = Some(value.to_string()),
QueryParameter::Message => self.message = Some(value.to_string()),
QueryParameter::Memo => self.memo = Some(value.to_string()),
QueryParameter::Extension(extension) => self
.extensions
.push((extension.to_owned(), value.to_string())),
};
Ok(())
}
}
impl From<SolanaPayRequest> for Url {
fn from(url_params: SolanaPayRequest) -> Url {
let mut url = Url::parse(&format!("{}:{}", SOLANA_PAY_SCHEME, url_params.recipient))
.expect("pubkey contained invalid URL characters");
url_params.into_iter().for_each(|(name, value)| {
url.query_pairs_mut().append_pair(&name, &value);
});
url
}
}
impl TryFrom<&String> for SolanaPayRequest {
type Error = SolanaPayError;
fn try_from(url: &String) -> Result<SolanaPayRequest, SolanaPayError> {
if url.len() > 2048 {
return Err(SolanaPayError::UrlInvalidLength(url.len()));
}
let url = Url::parse(url)?;
if url.scheme() != SOLANA_PAY_SCHEME {
return Err(SolanaPayError::IncorrectScheme(url.scheme().to_string()));
}
let mut params = SolanaPayRequest {
recipient: Pubkey::from_str(url.path())?,
..Default::default()
};
for (token, value) in tokenize_params(url)? {
params.append_value(&token, value.as_ref())?;
}
Ok(params)
}
}
#[cfg(test)]
mod test {
use super::*;
#[test]
fn test_decoding_url() {
let param_struct = SolanaPayRequest::try_from(&format!(
"solana:{}?amount=100&label=label+with+spaces&message=message+with+spaces",
Pubkey::new_unique().to_string()
))
.unwrap();
assert_eq!(param_struct.amount, Some(100 as f64));
assert_eq!(param_struct.label.unwrap(), "label with spaces".to_string());
assert_eq!(param_struct.references, vec![]);
assert_eq!(param_struct.memo, None);
assert_eq!(
param_struct.message.unwrap(),
"message with spaces".to_string()
);
}
#[test]
fn test_encoding_url() {
let recipient = Pubkey::new_unique();
let param_struct = SolanaPayRequest {
recipient: recipient.clone(),
amount: Some(100.0),
spl_token: None,
references: vec![],
label: Some("label with spaces".to_string()),
message: Some("message with spaces".to_string()),
memo: Some("memo with spaces".to_string()),
extensions: vec![],
};
assert_eq!(
Url::from(param_struct).to_string(),
format!(
"solana:{}?amount=100&label=label+with+spaces&message=message+with+spaces&memo=memo+with+spaces",
recipient.to_string()
)
);
}
#[test]
fn test_encoding_url_with_extensions() {
let recipient = Pubkey::new_unique();
let param_struct = SolanaPayRequest {
recipient: recipient.clone(),
amount: Some(100.0),
spl_token: None,
references: vec![],
label: Some("label with spaces".to_string()),
message: Some("message with spaces".to_string()),
memo: Some("memo with spaces".to_string()),
extensions: vec![("a", "1"), ("b", "2")]
.into_iter()
.map(|(x, y)| (x.to_string(), y.to_string()))
.collect(),
};
assert_eq!(
Url::from(param_struct).to_string(),
format!(
"solana:{}?amount=100&label=label+with+spaces&message=message+with+spaces&memo=memo+with+spaces&a=1&b=2",
recipient.to_string()
)
);
}
#[test]
fn test_assert_amount_is_0_padded() {
let recipient = Pubkey::new_unique();
let param_struct = SolanaPayRequest {
recipient: recipient.clone(),
amount: Some(0.123),
spl_token: None,
references: vec![],
label: None,
message: None,
memo: None,
extensions: vec![],
};
assert_eq!(
Url::from(param_struct).to_string(),
format!("solana:{}?amount=0.123", recipient.to_string())
);
}
#[test]
fn test_multiple_query_params_decode_reference() {
let r1 = Pubkey::new_unique();
let r2 = Pubkey::new_unique();
let param_struct = SolanaPayRequest::try_from(&format!(
"solana:{}?reference={}&reference={}",
Pubkey::new_unique().to_string(),
r1,
r2
))
.unwrap();
assert_eq!(param_struct.references, vec![r1, r2]);
}
#[test]
fn test_multiple_query_params_extensions() {
let param_struct = SolanaPayRequest::try_from(&format!(
"solana:{}?extension_1=a&extension_2=b",
Pubkey::new_unique().to_string(),
))
.unwrap();
assert_eq!(
param_struct.extensions,
vec![
("extension_1".to_string(), "a".to_string()),
("extension_2".to_string(), "b".to_string())
]
);
}
#[test]
fn test_dont_allow_multiple_query_params() {
let param_struct = SolanaPayRequest::try_from(&format!(
"solana:{}?amount=0&amount=0",
Pubkey::new_unique().to_string(),
));
assert!(param_struct.is_err());
}
}