//! Reference implementation of the ZIP-321 standard for payment requests. //! //! This crate provides data structures, parsing, and rendering functions //! for interpreting and producing valid ZIP 321 URIs. //! //! The specification for ZIP 321 URIs may be found at use core::fmt::Debug; use std::{ collections::BTreeMap, fmt::{self, Display}, }; use base64::{prelude::BASE64_URL_SAFE_NO_PAD, Engine}; use nom::{ character::complete::char, combinator::all_consuming, multi::separated_list0, sequence::preceded, }; use zcash_address::{ConversionError, ZcashAddress}; use zcash_protocol::{ memo::{self, MemoBytes}, value::BalanceError, value::Zatoshis, }; /// Errors that may be produced in decoding of payment requests. #[derive(Debug, Clone, PartialEq, Eq)] pub enum Zip321Error { /// A memo field in the ZIP 321 URI was not properly base-64 encoded InvalidBase64(base64::DecodeError), /// A memo value exceeded 512 bytes in length or could not be interpreted as a UTF-8 string /// when using a valid UTF-8 lead byte. MemoBytesError(memo::Error), /// The ZIP 321 request included more payments than can be created within a single Zcash /// transaction. The wrapped value is the number of payments in the request. TooManyPayments(usize), /// Parsing encountered a duplicate ZIP 321 URI parameter for the returned payment index. DuplicateParameter(parse::Param, usize), /// The payment at the wrapped index attempted to include a memo when sending to a /// transparent recipient address, which is not supported by the protocol. TransparentMemo(usize), /// The payment at the wrapped index did not include a recipient address. RecipientMissing(usize), /// The ZIP 321 URI was malformed and failed to parse. ParseError(String), } impl From> for Zip321Error { fn from(value: ConversionError) -> Self { Zip321Error::ParseError(format!("Address parsing failed: {}", value)) } } impl Display for Zip321Error { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { Zip321Error::InvalidBase64(err) => { write!(f, "Memo value was not correctly base64-encoded: {:?}", err) } Zip321Error::MemoBytesError(err) => write!( f, "Memo exceeded maximum length or violated UTF-8 encoding restrictions: {:?}", err ), Zip321Error::TooManyPayments(n) => write!( f, "Cannot create a Zcash transaction containing {} payments", n ), Zip321Error::DuplicateParameter(param, idx) => write!( f, "There is a duplicate {} parameter at index {}", param.name(), idx ), Zip321Error::TransparentMemo(idx) => write!( f, "Payment {} is invalid: cannot send a memo to a transparent recipient address", idx ), Zip321Error::RecipientMissing(idx) => { write!(f, "Payment {} is missing its recipient address", idx) } Zip321Error::ParseError(s) => write!(f, "Parse failure: {}", s), } } } impl std::error::Error for Zip321Error { fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { match self { Zip321Error::InvalidBase64(err) => Some(err), Zip321Error::MemoBytesError(err) => Some(err), _ => None, } } } /// Converts a [`MemoBytes`] value to a ZIP 321 compatible base64-encoded string. /// /// [`MemoBytes`]: zcash_protocol::memo::MemoBytes pub fn memo_to_base64(memo: &MemoBytes) -> String { BASE64_URL_SAFE_NO_PAD.encode(memo.as_slice()) } /// Parse a [`MemoBytes`] value from a ZIP 321 compatible base64-encoded string. /// /// [`MemoBytes`]: zcash_protocol::memo::MemoBytes pub fn memo_from_base64(s: &str) -> Result { BASE64_URL_SAFE_NO_PAD .decode(s) .map_err(Zip321Error::InvalidBase64) .and_then(|b| MemoBytes::from_bytes(&b).map_err(Zip321Error::MemoBytesError)) } /// A single payment being requested. #[derive(Debug, Clone, PartialEq, Eq)] pub struct Payment { /// The address to which the payment should be sent. recipient_address: ZcashAddress, /// The amount of the payment that is being requested. amount: Zatoshis, /// A memo that, if included, must be provided with the payment. /// If a memo is present and [`recipient_address`] is not a shielded /// address, the wallet should report an error. /// /// [`recipient_address`]: #structfield.recipient_address memo: Option, /// A human-readable label for this payment within the larger structure /// of the transaction request. label: Option, /// A human-readable message to be displayed to the user describing the /// purpose of this payment. message: Option, /// A list of other arbitrary key/value pairs associated with this payment. other_params: Vec<(String, String)>, } impl Payment { /// Constructs a new [`Payment`] from its constituent parts. /// /// Returns `None` if the payment requests that a memo be sent to a recipient that cannot /// receive a memo. pub fn new( recipient_address: ZcashAddress, amount: Zatoshis, memo: Option, label: Option, message: Option, other_params: Vec<(String, String)>, ) -> Option { if memo.is_none() || recipient_address.can_receive_memo() { Some(Self { recipient_address, amount, memo, label, message, other_params, }) } else { None } } /// Constructs a new [`Payment`] paying the given address the specified amount. pub fn without_memo(recipient_address: ZcashAddress, amount: Zatoshis) -> Self { Self { recipient_address, amount, memo: None, label: None, message: None, other_params: vec![], } } /// Returns the payment address to which the payment should be sent. pub fn recipient_address(&self) -> &ZcashAddress { &self.recipient_address } /// Returns the value of the payment that is being requested, in zatoshis. pub fn amount(&self) -> Zatoshis { self.amount } /// Returns the memo that, if included, must be provided with the payment. pub fn memo(&self) -> Option<&MemoBytes> { self.memo.as_ref() } /// A human-readable label for this payment within the larger structure /// of the transaction request. pub fn label(&self) -> Option<&String> { self.label.as_ref() } /// A human-readable message to be displayed to the user describing the /// purpose of this payment. pub fn message(&self) -> Option<&String> { self.message.as_ref() } /// A list of other arbitrary key/value pairs associated with this payment. pub fn other_params(&self) -> &[(String, String)] { self.other_params.as_ref() } /// A utility for use in tests to help check round-trip serialization properties. #[cfg(any(test, feature = "test-dependencies"))] pub(crate) fn normalize(&mut self) { self.other_params.sort(); } } /// A ZIP321 transaction request. /// /// A ZIP 321 request may include one or more such requests for payment. /// When constructing a transaction in response to such a request, /// a separate output should be added to the transaction for each /// payment value in the request. #[derive(Debug, Clone, PartialEq, Eq)] pub struct TransactionRequest { payments: BTreeMap, } impl TransactionRequest { /// Constructs a new empty transaction request. pub fn empty() -> Self { Self { payments: BTreeMap::new(), } } /// Constructs a new transaction request that obeys the ZIP-321 invariants. pub fn new(payments: Vec) -> Result { // Payment indices are limited to 4 digits if payments.len() > 9999 { return Err(Zip321Error::TooManyPayments(payments.len())); } let request = TransactionRequest { payments: payments.into_iter().enumerate().collect(), }; // Enforce validity requirements. if !request.payments.is_empty() { TransactionRequest::from_uri(&request.to_uri())?; } Ok(request) } /// Constructs a new transaction request from the provided map from payment /// index to payment. /// /// Payment index 0 will be mapped to the empty payment index. pub fn from_indexed( payments: BTreeMap, ) -> Result { if let Some(k) = payments.keys().find(|k| **k > 9999) { // This is not quite the correct error, but close enough. return Err(Zip321Error::TooManyPayments(*k)); } Ok(TransactionRequest { payments }) } /// Returns the map of payments that make up this request. /// /// This is a map from payment index to payment. Payment index `0` is used to denote /// the empty payment index in the returned values. pub fn payments(&self) -> &BTreeMap { &self.payments } /// Returns the total value of payments to be made. /// /// Returns `Err` in the case of overflow, or if the value is /// outside the range `0..=MAX_MONEY` zatoshis. pub fn total(&self) -> Result { self.payments .values() .map(|p| p.amount) .fold(Ok(Zatoshis::ZERO), |acc, a| { (acc? + a).ok_or(BalanceError::Overflow) }) } /// A utility for use in tests to help check round-trip serialization properties. #[cfg(any(test, feature = "test-dependencies"))] pub(crate) fn normalize(&mut self) { for p in &mut self.payments.values_mut() { p.normalize(); } } /// A utility for use in tests to help check round-trip serialization properties. /// by comparing a two transaction requests for equality after normalization. #[cfg(test)] pub(crate) fn normalize_and_eq(a: &mut TransactionRequest, b: &mut TransactionRequest) -> bool { a.normalize(); b.normalize(); a == b } /// Convert this request to a URI string. /// /// Returns None if the payment request is empty. pub fn to_uri(&self) -> String { fn payment_params( payment: &Payment, payment_index: Option, ) -> impl IntoIterator + '_ { std::iter::empty() .chain(Some(render::amount_param(payment.amount, payment_index))) .chain( payment .memo .as_ref() .map(|m| render::memo_param(m, payment_index)), ) .chain( payment .label .as_ref() .map(|m| render::str_param("label", m, payment_index)), ) .chain( payment .message .as_ref() .map(|m| render::str_param("message", m, payment_index)), ) .chain( payment .other_params .iter() .map(move |(name, value)| render::str_param(name, value, payment_index)), ) } match self.payments.len() { 0 => "zcash:".to_string(), 1 if *self.payments.iter().next().unwrap().0 == 0 => { let (_, payment) = self.payments.iter().next().unwrap(); let query_params = payment_params(payment, None) .into_iter() .collect::>(); format!( "zcash:{}{}{}", payment.recipient_address.encode(), if query_params.is_empty() { "" } else { "?" }, query_params.join("&") ) } _ => { let query_params = self .payments .iter() .flat_map(|(i, payment)| { let idx = if *i == 0 { None } else { Some(*i) }; let primary_address = payment.recipient_address.clone(); std::iter::empty() .chain(Some(render::addr_param(&primary_address, idx))) .chain(payment_params(payment, idx)) }) .collect::>(); format!("zcash:?{}", query_params.join("&")) } } } /// Parse the provided URI to a payment request value. pub fn from_uri(uri: &str) -> Result { // Parse the leading zcash:
let (rest, primary_addr_param) = parse::lead_addr(uri) .map_err(|e| Zip321Error::ParseError(format!("Error parsing lead address: {}", e)))?; // Parse the remaining parameters as an undifferentiated list let (_, xs) = if rest.is_empty() { ("", vec![]) } else { all_consuming(preceded( char('?'), separated_list0(char('&'), parse::zcashparam), ))(rest) .map_err(|e| { Zip321Error::ParseError(format!("Error parsing query parameters: {}", e)) })? }; // Construct sets of payment parameters, keyed by the payment index. let mut params_by_index: BTreeMap> = BTreeMap::new(); // Add the primary address, if any, to the index. if let Some(p) = primary_addr_param { params_by_index.insert(p.payment_index, vec![p.param]); } // Group the remaining parameters by payment index for p in xs { match params_by_index.get_mut(&p.payment_index) { None => { params_by_index.insert(p.payment_index, vec![p.param]); } Some(current) => { if parse::has_duplicate_param(current, &p.param) { return Err(Zip321Error::DuplicateParameter(p.param, p.payment_index)); } else { current.push(p.param); } } } } // Build the actual payment values from the index. params_by_index .into_iter() .map(|(i, params)| parse::to_payment(params, i).map(|payment| (i, payment))) .collect::, _>>() .map(|payments| TransactionRequest { payments }) } } mod render { use percent_encoding::{utf8_percent_encode, AsciiSet, CONTROLS}; use zcash_address::ZcashAddress; use zcash_protocol::{ memo::MemoBytes, value::{Zatoshis, COIN}, }; use super::memo_to_base64; /// The set of ASCII characters that must be percent-encoded according /// to the definition of ZIP 321. This is the complement of the subset of /// ASCII characters defined by `qchar` /// // unreserved = ALPHA / DIGIT / "-" / "." / "_" / "~" // allowed-delims = "!" / "$" / "'" / "(" / ")" / "*" / "+" / "," / ";" // qchar = unreserved / pct-encoded / allowed-delims / ":" / "@" pub const QCHAR_ENCODE: &AsciiSet = &CONTROLS .add(b' ') .add(b'"') .add(b'#') .add(b'%') .add(b'&') .add(b'/') .add(b'<') .add(b'=') .add(b'>') .add(b'?') .add(b'[') .add(b'\\') .add(b']') .add(b'^') .add(b'`') .add(b'{') .add(b'|') .add(b'}'); /// Converts a parameter index value to the `String` representation /// that must be appended to a parameter name when constructing a ZIP 321 URI. pub fn param_index(idx: Option) -> String { match idx { Some(i) if i > 0 => format!(".{}", i), _otherwise => "".to_string(), } } /// Constructs an "address" key/value pair containing the encoded recipient address /// at the specified parameter index. pub fn addr_param(addr: &ZcashAddress, idx: Option) -> String { format!("address{}={}", param_index(idx), addr.encode()) } /// Converts a [`Zatoshis`] value to a correctly formatted decimal ZEC /// value for inclusion in a ZIP 321 URI. pub fn amount_str(amount: Zatoshis) -> String { let coins = u64::from(amount) / COIN; let zats = u64::from(amount) % COIN; if zats == 0 { format!("{}", coins) } else { format!("{}.{:0>8}", coins, zats) .trim_end_matches('0') .to_string() } } /// Constructs an "amount" key/value pair containing the encoded ZEC amount /// at the specified parameter index. pub fn amount_param(amount: Zatoshis, idx: Option) -> String { format!("amount{}={}", param_index(idx), amount_str(amount)) } /// Constructs a "memo" key/value pair containing the base64URI-encoded memo /// at the specified parameter index. pub fn memo_param(value: &MemoBytes, idx: Option) -> String { format!("{}{}={}", "memo", param_index(idx), memo_to_base64(value)) } /// Utility function for an arbitrary string key/value pair for inclusion in /// a ZIP 321 URI at the specified parameter index. pub fn str_param(label: &str, value: &str, idx: Option) -> String { format!( "{}{}={}", label, param_index(idx), utf8_percent_encode(value, QCHAR_ENCODE) ) } } mod parse { use core::fmt::Debug; use nom::{ bytes::complete::{tag, take_till}, character::complete::{alpha1, char, digit0, digit1, one_of}, combinator::{all_consuming, map_opt, map_res, opt, recognize}, sequence::{preceded, separated_pair, tuple}, AsChar, IResult, InputTakeAtPosition, }; use percent_encoding::percent_decode; use zcash_address::ZcashAddress; use zcash_protocol::value::BalanceError; use zcash_protocol::{ memo::MemoBytes, value::{Zatoshis, COIN}, }; use super::{memo_from_base64, Payment, Zip321Error}; /// A data type that defines the possible parameter types which may occur within a /// ZIP 321 URI. #[derive(Debug, Clone, PartialEq, Eq)] pub enum Param { Addr(Box), Amount(Zatoshis), Memo(Box), Label(String), Message(String), Other(String, String), } impl Param { /// Returns the name of the parameter from which this value was parsed. pub fn name(&self) -> String { match self { Param::Addr(_) => "address".to_owned(), Param::Amount(_) => "amount".to_owned(), Param::Memo(_) => "memo".to_owned(), Param::Label(_) => "label".to_owned(), Param::Message(_) => "message".to_owned(), Param::Other(name, _) => name.clone(), } } } /// A [`Param`] value with its associated index. #[derive(Debug, Clone, PartialEq, Eq)] pub struct IndexedParam { pub param: Param, pub payment_index: usize, } /// Utility function for determining parameter uniqueness. /// /// Utility function for determining whether a newly parsed param is a duplicate /// of a previous parameter. pub fn has_duplicate_param(v: &[Param], p: &Param) -> bool { for p0 in v { match (p0, p) { (Param::Addr(_), Param::Addr(_)) => return true, (Param::Amount(_), Param::Amount(_)) => return true, (Param::Memo(_), Param::Memo(_)) => return true, (Param::Label(_), Param::Label(_)) => return true, (Param::Message(_), Param::Message(_)) => return true, (Param::Other(n, _), Param::Other(n0, _)) if (n == n0) => return true, _otherwise => continue, } } false } /// Converts an vector of [`Param`] values to a [`Payment`]. /// /// This function performs checks to ensure that the resulting [`Payment`] is structurally /// valid; for example, a request for memo contents may not be associated with a /// transparent payment address. pub fn to_payment(vs: Vec, i: usize) -> Result { let addr = vs.iter().find_map(|v| match v { Param::Addr(a) => Some(a.clone()), _otherwise => None, }); let mut payment = Payment { recipient_address: *addr.ok_or(Zip321Error::RecipientMissing(i))?, amount: Zatoshis::ZERO, memo: None, label: None, message: None, other_params: vec![], }; for v in vs { match v { Param::Amount(a) => payment.amount = a, Param::Memo(m) => { if payment.recipient_address.can_receive_memo() { payment.memo = Some(*m); } else { return Err(Zip321Error::TransparentMemo(i)); } } Param::Label(m) => payment.label = Some(m), Param::Message(m) => payment.message = Some(m), Param::Other(n, m) => payment.other_params.push((n, m)), _otherwise => {} } } Ok(payment) } /// Parses and consumes the leading "zcash:\[address\]" from a ZIP 321 URI. pub fn lead_addr(input: &str) -> IResult<&str, Option> { map_opt( preceded(tag("zcash:"), take_till(|c| c == '?')), |addr_str: &str| { if addr_str.is_empty() { Some(None) // no address is ok, so wrap in `Some` } else { // `try_from_encoded(..).ok()` returns `None` on error, which we want to then // cause `map_opt` to fail. ZcashAddress::try_from_encoded(addr_str) .map(|a| { Some(IndexedParam { param: Param::Addr(Box::new(a)), payment_index: 0, }) }) .ok() } }, )(input) } /// The primary parser for = query-string parameter pair. pub fn zcashparam(input: &str) -> IResult<&str, IndexedParam> { map_res( separated_pair(indexed_name, char('='), recognize(qchars)), to_indexed_param, )(input) } /// Extension for the `alphanumeric0` parser which extends that parser /// by also permitting the characters that are members of the `allowed` /// string. fn alphanum_or(allowed: &str) -> impl (Fn(&str) -> IResult<&str, &str>) + '_ { move |input| { input.split_at_position_complete(|item| { let c = item.as_char(); !(c.is_alphanum() || allowed.contains(c)) }) } } /// Parses valid characters which may appear in parameter values. pub fn qchars(input: &str) -> IResult<&str, &str> { alphanum_or("-._~!$'()*+,;:@%")(input) } /// Parses valid characters that may appear in parameter names. pub fn namechars(input: &str) -> IResult<&str, &str> { alphanum_or("+-")(input) } /// Parses a parameter name and its associated index. pub fn indexed_name(input: &str) -> IResult<&str, (&str, Option<&str>)> { let paramname = recognize(tuple((alpha1, namechars))); tuple(( paramname, opt(preceded( char('.'), recognize(tuple(( one_of("123456789"), map_opt(digit0, |s: &str| if s.len() > 3 { None } else { Some(s) }), ))), )), ))(input) } /// Parses a value in decimal ZEC. pub fn parse_amount(input: &str) -> IResult<&str, Zatoshis> { map_res( all_consuming(tuple(( digit1, opt(preceded( char('.'), map_opt(digit1, |s: &str| if s.len() > 8 { None } else { Some(s) }), )), ))), |(whole_s, decimal_s): (&str, Option<&str>)| { let coins: u64 = whole_s .to_string() .parse::() .map_err(|e| e.to_string())?; let zats: u64 = match decimal_s { Some(d) => format!("{:0<8}", d) .parse::() .map_err(|e| e.to_string())?, None => 0, }; coins .checked_mul(COIN) .and_then(|coin_zats| coin_zats.checked_add(zats)) .ok_or(BalanceError::Overflow) .and_then(Zatoshis::from_u64) .map_err(|_| format!("Not a valid zat amount: {}.{}", coins, zats)) }, )(input) } fn to_indexed_param( ((name, iopt), value): ((&str, Option<&str>), &str), ) -> Result { let param = match name { "address" => ZcashAddress::try_from_encoded(value) .map(Box::new) .map(Param::Addr) .map_err(|err| { format!( "Could not interpret {} as a valid Zcash address: {}", value, err ) }), "amount" => parse_amount(value) .map_err(|e| e.to_string()) .map(|(_, a)| Param::Amount(a)), "label" => percent_decode(value.as_bytes()) .decode_utf8() .map(|s| Param::Label(s.into_owned())) .map_err(|e| e.to_string()), "message" => percent_decode(value.as_bytes()) .decode_utf8() .map(|s| Param::Message(s.into_owned())) .map_err(|e| e.to_string()), "memo" => memo_from_base64(value) .map(Box::new) .map(Param::Memo) .map_err(|e| format!("Decoded memo was invalid: {:?}", e)), other if other.starts_with("req-") => { Err(format!("Required parameter {} not recognized", other)) } other => percent_decode(value.as_bytes()) .decode_utf8() .map(|s| Param::Other(other.to_string(), s.into_owned())) .map_err(|e| e.to_string()), }?; let payment_index = match iopt { Some(istr) => istr.parse::().map(Some).map_err(|e| e.to_string()), None => Ok(None), }?; Ok(IndexedParam { param, payment_index: payment_index.unwrap_or(0), }) } } #[cfg(any(test, feature = "test-dependencies"))] pub mod testing { use proptest::collection::btree_map; use proptest::collection::vec; use proptest::option; use proptest::prelude::{any, prop_compose}; use zcash_address::testing::arb_address; use zcash_protocol::{consensus::NetworkType, value::testing::arb_zatoshis}; use super::{MemoBytes, Payment, TransactionRequest}; pub const VALID_PARAMNAME: &str = "[a-zA-Z][a-zA-Z0-9+-]*"; prop_compose! { pub fn arb_valid_memo()(bytes in vec(any::(), 0..512)) -> MemoBytes { MemoBytes::from_bytes(&bytes).unwrap() } } prop_compose! { pub fn arb_zip321_payment(network: NetworkType)( recipient_address in arb_address(network), amount in arb_zatoshis(), memo in option::of(arb_valid_memo()), message in option::of(any::()), label in option::of(any::()), // prevent duplicates by generating a set rather than a vec other_params in btree_map(VALID_PARAMNAME, any::(), 0..3), ) -> Payment { let memo = memo.filter(|_| recipient_address.can_receive_memo()); Payment { recipient_address, amount, memo, label, message, other_params: other_params.into_iter().collect(), } } } prop_compose! { pub fn arb_zip321_request(network: NetworkType)( payments in btree_map(0usize..10000, arb_zip321_payment(network), 1..10) ) -> TransactionRequest { let mut req = TransactionRequest::from_indexed(payments).unwrap(); req.normalize(); // just to make test comparisons easier req } } prop_compose! { pub fn arb_zip321_request_sequential(network: NetworkType)( payments in vec(arb_zip321_payment(network), 1..10) ) -> TransactionRequest { let mut req = TransactionRequest::new(payments).unwrap(); req.normalize(); // just to make test comparisons easier req } } prop_compose! { pub fn arb_zip321_uri(network: NetworkType)(req in arb_zip321_request(network)) -> String { req.to_uri() } } prop_compose! { pub fn arb_addr_str(network: NetworkType)( recipient_address in arb_address(network) ) -> String { recipient_address.encode() } } } #[cfg(test)] mod tests { use proptest::prelude::{any, proptest}; use std::str::FromStr; use zcash_address::{testing::arb_address, ZcashAddress}; use zcash_protocol::{ consensus::NetworkType, memo::{Memo, MemoBytes}, value::{testing::arb_zatoshis, Zatoshis}, }; #[cfg(feature = "local-consensus")] use zcash_protocol::{local_consensus::LocalNetwork, BlockHeight}; use super::{ memo_from_base64, memo_to_base64, parse::{parse_amount, zcashparam, Param}, render::{amount_str, memo_param, str_param}, testing::{arb_addr_str, arb_valid_memo, arb_zip321_request, arb_zip321_uri}, Payment, TransactionRequest, }; fn check_roundtrip(req: TransactionRequest) { let req_uri = req.to_uri(); let parsed = TransactionRequest::from_uri(&req_uri).unwrap(); assert_eq!(parsed, req); } #[test] fn test_zip321_roundtrip_simple_amounts() { let amounts = vec![1u64, 1000u64, 100000u64, 100000000u64, 100000000000u64]; for amt_u64 in amounts { let amt = Zatoshis::const_from_u64(amt_u64); let amt_str = amount_str(amt); assert_eq!(amt, parse_amount(&amt_str).unwrap().1); } } #[test] fn test_zip321_parse_empty_message() { let fragment = "message="; let result = zcashparam(fragment).unwrap().1.param; assert_eq!(result, Param::Message("".to_string())); } #[test] fn test_zip321_parse_simple() { let uri = "zcash:ztestsapling1n65uaftvs2g7075q2x2a04shfk066u3lldzxsrprfrqtzxnhc9ps73v4lhx4l9yfxj46sl0q90k?amount=3768769.02796286&message="; let parse_result = TransactionRequest::from_uri(uri).unwrap(); let expected = TransactionRequest::new( vec![ Payment { recipient_address: ZcashAddress::try_from_encoded("ztestsapling1n65uaftvs2g7075q2x2a04shfk066u3lldzxsrprfrqtzxnhc9ps73v4lhx4l9yfxj46sl0q90k").unwrap(), amount: Zatoshis::const_from_u64(376876902796286), memo: None, label: None, message: Some("".to_string()), other_params: vec![], } ] ).unwrap(); assert_eq!(parse_result, expected); } #[test] fn test_zip321_parse_no_query_params() { let uri = "zcash:ztestsapling1n65uaftvs2g7075q2x2a04shfk066u3lldzxsrprfrqtzxnhc9ps73v4lhx4l9yfxj46sl0q90k"; let parse_result = TransactionRequest::from_uri(uri).unwrap(); let expected = TransactionRequest::new( vec![ Payment { recipient_address: ZcashAddress::try_from_encoded("ztestsapling1n65uaftvs2g7075q2x2a04shfk066u3lldzxsrprfrqtzxnhc9ps73v4lhx4l9yfxj46sl0q90k").unwrap(), amount: Zatoshis::ZERO, memo: None, label: None, message: None, other_params: vec![], } ] ).unwrap(); assert_eq!(parse_result, expected); } #[test] fn test_zip321_roundtrip_empty_message() { let req = TransactionRequest::new( vec![ Payment { recipient_address: ZcashAddress::try_from_encoded("ztestsapling1n65uaftvs2g7075q2x2a04shfk066u3lldzxsrprfrqtzxnhc9ps73v4lhx4l9yfxj46sl0q90k").unwrap(), amount: Zatoshis::ZERO, memo: None, label: None, message: Some("".to_string()), other_params: vec![] } ] ).unwrap(); check_roundtrip(req); } #[test] fn test_zip321_memos() { let m_simple: MemoBytes = Memo::from_str("This is a simple memo.").unwrap().into(); let m_simple_64 = memo_to_base64(&m_simple); assert_eq!(memo_from_base64(&m_simple_64).unwrap(), m_simple); let m_json: MemoBytes = Memo::from_str("{ \"key\": \"This is a JSON-structured memo.\" }") .unwrap() .into(); let m_json_64 = memo_to_base64(&m_json); assert_eq!(memo_from_base64(&m_json_64).unwrap(), m_json); let m_unicode: MemoBytes = Memo::from_str("This is a unicode memo ✨🦄🏆🎉") .unwrap() .into(); let m_unicode_64 = memo_to_base64(&m_unicode); assert_eq!(memo_from_base64(&m_unicode_64).unwrap(), m_unicode); } #[test] fn test_zip321_spec_valid_examples() { let valid_0 = "zcash:"; let v0r = TransactionRequest::from_uri(valid_0).unwrap(); assert!(v0r.payments.is_empty()); let valid_0 = "zcash:?"; let v0r = TransactionRequest::from_uri(valid_0).unwrap(); assert!(v0r.payments.is_empty()); let valid_1 = "zcash:ztestsapling10yy2ex5dcqkclhc7z7yrnjq2z6feyjad56ptwlfgmy77dmaqqrl9gyhprdx59qgmsnyfska2kez?amount=1&memo=VGhpcyBpcyBhIHNpbXBsZSBtZW1vLg&message=Thank%20you%20for%20your%20purchase"; let v1r = TransactionRequest::from_uri(valid_1).unwrap(); assert_eq!( v1r.payments.get(&0).map(|p| p.amount), Some(Zatoshis::const_from_u64(100000000)) ); let valid_2 = "zcash:?address=tmEZhbWHTpdKMw5it8YDspUXSMGQyFwovpU&amount=123.456&address.1=ztestsapling10yy2ex5dcqkclhc7z7yrnjq2z6feyjad56ptwlfgmy77dmaqqrl9gyhprdx59qgmsnyfska2kez&amount.1=0.789&memo.1=VGhpcyBpcyBhIHVuaWNvZGUgbWVtbyDinKjwn6aE8J-PhvCfjok"; let mut v2r = TransactionRequest::from_uri(valid_2).unwrap(); v2r.normalize(); assert_eq!( v2r.payments.get(&0).map(|p| p.amount), Some(Zatoshis::const_from_u64(12345600000)) ); assert_eq!( v2r.payments.get(&1).map(|p| p.amount), Some(Zatoshis::const_from_u64(78900000)) ); // valid; amount just less than MAX_MONEY // 20999999.99999999 let valid_3 = "zcash:ztestsapling10yy2ex5dcqkclhc7z7yrnjq2z6feyjad56ptwlfgmy77dmaqqrl9gyhprdx59qgmsnyfska2kez?amount=20999999.99999999"; let v3r = TransactionRequest::from_uri(valid_3).unwrap(); assert_eq!( v3r.payments.get(&0).map(|p| p.amount), Some(Zatoshis::const_from_u64(2099999999999999)) ); // valid; MAX_MONEY // 21000000 let valid_4 = "zcash:ztestsapling10yy2ex5dcqkclhc7z7yrnjq2z6feyjad56ptwlfgmy77dmaqqrl9gyhprdx59qgmsnyfska2kez?amount=21000000"; let v4r = TransactionRequest::from_uri(valid_4).unwrap(); assert_eq!( v4r.payments.get(&0).map(|p| p.amount), Some(Zatoshis::const_from_u64(2100000000000000)) ); } #[cfg(feature = "local-consensus")] #[test] fn test_zip321_spec_regtest_valid_examples() { let params = LocalNetwork { overwinter: Some(BlockHeight::from_u32(1)), sapling: Some(BlockHeight::from_u32(1)), blossom: Some(BlockHeight::from_u32(1)), heartwood: Some(BlockHeight::from_u32(1)), canopy: Some(BlockHeight::from_u32(1)), nu5: Some(BlockHeight::from_u32(1)), nu6: Some(BlockHeight::from_u32(1)), z_future: Some(BlockHeight::from_u32(1)), }; let valid_1 = "zcash:zregtestsapling1qqqqqqqqqqqqqqqqqqcguyvaw2vjk4sdyeg0lc970u659lvhqq7t0np6hlup5lusxle7505hlz3?amount=1&memo=VGhpcyBpcyBhIHNpbXBsZSBtZW1vLg&message=Thank%20you%20for%20your%20purchase"; let v1r = TransactionRequest::from_uri(¶ms, valid_1).unwrap(); assert_eq!( v1r.payments.get(&0).map(|p| p.amount), Some(Zatoshis::const_from_u64(100000000)) ); } #[test] fn test_zip321_spec_invalid_examples() { // invalid; empty string let invalid_0 = ""; let i0r = TransactionRequest::from_uri(invalid_0); assert!(i0r.is_err()); // invalid; missing `address=` let invalid_1 = "zcash:?amount=3491405.05201255&address.1=ztestsapling10yy2ex5dcqkclhc7z7yrnjq2z6feyjad56ptwlfgmy77dmaqqrl9gyhprdx59qgmsnyfska2kez&amount.1=5740296.87793245"; let i1r = TransactionRequest::from_uri(invalid_1); assert!(i1r.is_err()); // invalid; missing `address.1=` let invalid_2 = "zcash:?address=tmEZhbWHTpdKMw5it8YDspUXSMGQyFwovpU&amount=1&amount.1=2&address.2=ztestsapling10yy2ex5dcqkclhc7z7yrnjq2z6feyjad56ptwlfgmy77dmaqqrl9gyhprdx59qgmsnyfska2kez"; let i2r = TransactionRequest::from_uri(invalid_2); assert!(i2r.is_err()); // invalid; `address.0=` and `amount.0=` are not permitted (leading 0s). let invalid_3 = "zcash:?address.0=ztestsapling10yy2ex5dcqkclhc7z7yrnjq2z6feyjad56ptwlfgmy77dmaqqrl9gyhprdx59qgmsnyfska2kez&amount.0=2"; let i3r = TransactionRequest::from_uri(invalid_3); assert!(i3r.is_err()); // invalid; duplicate `amount=` field let invalid_4 = "zcash:?amount=1.234&amount=2.345&address=tmEZhbWHTpdKMw5it8YDspUXSMGQyFwovpU"; let i4r = TransactionRequest::from_uri(invalid_4); assert!(i4r.is_err()); // invalid; duplicate `amount.1=` field let invalid_5 = "zcash:?amount.1=1.234&amount.1=2.345&address.1=tmEZhbWHTpdKMw5it8YDspUXSMGQyFwovpU"; let i5r = TransactionRequest::from_uri(invalid_5); assert!(i5r.is_err()); //invalid; memo associated with t-addr let invalid_6 = "zcash:?address=tmEZhbWHTpdKMw5it8YDspUXSMGQyFwovpU&amount=123.456&memo=eyAia2V5IjogIlRoaXMgaXMgYSBKU09OLXN0cnVjdHVyZWQgbWVtby4iIH0&address.1=ztestsapling10yy2ex5dcqkclhc7z7yrnjq2z6feyjad56ptwlfgmy77dmaqqrl9gyhprdx59qgmsnyfska2kez&amount.1=0.789&memo.1=VGhpcyBpcyBhIHVuaWNvZGUgbWVtbyDinKjwn6aE8J-PhvCfjok"; let i6r = TransactionRequest::from_uri(invalid_6); assert!(i6r.is_err()); // invalid; amount component exceeds an i64 // 9223372036854775808 = i64::MAX + 1 let invalid_7 = "zcash:ztestsapling10yy2ex5dcqkclhc7z7yrnjq2z6feyjad56ptwlfgmy77dmaqqrl9gyhprdx59qgmsnyfska2kez?amount=9223372036854775808"; let i7r = TransactionRequest::from_uri(invalid_7); assert!(i7r.is_err()); // invalid; amount component wraps into a valid small positive i64 // 18446744073709551624 let invalid_7a = "zcash:ztestsapling10yy2ex5dcqkclhc7z7yrnjq2z6feyjad56ptwlfgmy77dmaqqrl9gyhprdx59qgmsnyfska2kez?amount=18446744073709551624"; let i7ar = TransactionRequest::from_uri(invalid_7a); assert!(i7ar.is_err()); // invalid; amount component is MAX_MONEY // 21000000.00000001 let invalid_8 = "zcash:ztestsapling10yy2ex5dcqkclhc7z7yrnjq2z6feyjad56ptwlfgmy77dmaqqrl9gyhprdx59qgmsnyfska2kez?amount=21000000.00000001"; let i8r = TransactionRequest::from_uri(invalid_8); assert!(i8r.is_err()); // invalid; negative amount let invalid_9 = "zcash:ztestsapling10yy2ex5dcqkclhc7z7yrnjq2z6feyjad56ptwlfgmy77dmaqqrl9gyhprdx59qgmsnyfska2kez?amount=-1"; let i9r = TransactionRequest::from_uri(invalid_9); assert!(i9r.is_err()); // invalid; parameter index too large let invalid_10 = "zcash:?amount.10000=1.23&address.10000=tmEZhbWHTpdKMw5it8YDspUXSMGQyFwovpU"; let i10r = TransactionRequest::from_uri(invalid_10); assert!(i10r.is_err()); // invalid: bad amount format let invalid_11 = "zcash:?address=tmEZhbWHTpdKMw5it8YDspUXSMGQyFwovpU&amount=123."; let i11r = TransactionRequest::from_uri(invalid_11); assert!(i11r.is_err()); } proptest! { #[test] fn prop_zip321_roundtrip_address(addr in arb_address(NetworkType::Test)) { let a = addr.encode(); assert_eq!(ZcashAddress::try_from_encoded(&a), Ok(addr)); } #[test] fn prop_zip321_roundtrip_address_str(a in arb_addr_str(NetworkType::Test)) { let addr = ZcashAddress::try_from_encoded(&a).unwrap(); assert_eq!(addr.encode(), a); } #[test] fn prop_zip321_roundtrip_amount(amt in arb_zatoshis()) { let amt_str = amount_str(amt); assert_eq!(amt, parse_amount(&amt_str).unwrap().1); } #[test] fn prop_zip321_roundtrip_str_param( message in any::(), i in proptest::option::of(0usize..2000)) { let fragment = str_param("message", &message, i); let (rest, iparam) = zcashparam(&fragment).unwrap(); assert_eq!(rest, ""); assert_eq!(iparam.param, Param::Message(message)); assert_eq!(iparam.payment_index, i.unwrap_or(0)); } #[test] fn prop_zip321_roundtrip_memo_param( memo in arb_valid_memo(), i in proptest::option::of(0usize..2000)) { let fragment = memo_param(&memo, i); let (rest, iparam) = zcashparam(&fragment).unwrap(); assert_eq!(rest, ""); assert_eq!(iparam.param, Param::Memo(Box::new(memo))); assert_eq!(iparam.payment_index, i.unwrap_or(0)); } #[test] fn prop_zip321_roundtrip_request(mut req in arb_zip321_request(NetworkType::Test)) { let req_uri = req.to_uri(); let mut parsed = TransactionRequest::from_uri(&req_uri).unwrap(); assert!(TransactionRequest::normalize_and_eq(&mut parsed, &mut req)); } #[test] fn prop_zip321_roundtrip_uri(uri in arb_zip321_uri(NetworkType::Test)) { let mut parsed = TransactionRequest::from_uri(&uri).unwrap(); parsed.normalize(); let serialized = parsed.to_uri(); assert_eq!(serialized, uri) } } }