From 27058bbc4d4e06a4484f0260a9deb6da291971a1 Mon Sep 17 00:00:00 2001 From: Yuval Kogman Date: Sun, 6 Jul 2025 19:12:53 +0200 Subject: [PATCH 1/5] Remove redundant v2 feature gating The `url_ext` mod is itself feature gated, so any declarations within it are already implicitly v2 only. --- payjoin/src/core/uri/url_ext.rs | 6 ------ 1 file changed, 6 deletions(-) diff --git a/payjoin/src/core/uri/url_ext.rs b/payjoin/src/core/uri/url_ext.rs index 9a835f2eb..e263c1819 100644 --- a/payjoin/src/core/uri/url_ext.rs +++ b/payjoin/src/core/uri/url_ext.rs @@ -142,14 +142,12 @@ fn set_param(url: &mut Url, prefix: &str, param: &str) { url.set_fragment(if fragment.is_empty() { None } else { Some(&fragment) }); } -#[cfg(feature = "v2")] #[derive(Debug)] pub(crate) enum ParseOhttpKeysParamError { MissingOhttpKeys, InvalidOhttpKeys(crate::ohttp::ParseOhttpKeysError), } -#[cfg(feature = "v2")] impl std::fmt::Display for ParseOhttpKeysParamError { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { use ParseOhttpKeysParamError::*; @@ -161,7 +159,6 @@ impl std::fmt::Display for ParseOhttpKeysParamError { } } -#[cfg(feature = "v2")] #[derive(Debug)] pub(crate) enum ParseExpParamError { MissingExp, @@ -170,7 +167,6 @@ pub(crate) enum ParseExpParamError { InvalidExp(bitcoin::consensus::encode::Error), } -#[cfg(feature = "v2")] impl std::fmt::Display for ParseExpParamError { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { use ParseExpParamError::*; @@ -194,7 +190,6 @@ pub(crate) enum ParseReceiverPubkeyParamError { InvalidPubkey(crate::hpke::HpkeError), } -#[cfg(feature = "v2")] impl std::fmt::Display for ParseReceiverPubkeyParamError { fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { use ParseReceiverPubkeyParamError::*; @@ -209,7 +204,6 @@ impl std::fmt::Display for ParseReceiverPubkeyParamError { } } -#[cfg(feature = "v2")] impl std::error::Error for ParseReceiverPubkeyParamError { fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { use ParseReceiverPubkeyParamError::*; From b61eb7b2d99546758567f103dc1be7cc16a90d3d Mon Sep 17 00:00:00 2001 From: Yuval Kogman Date: Sat, 5 Jul 2025 13:56:35 +0200 Subject: [PATCH 2/5] Fix off by one in set_param --- payjoin/src/core/uri/url_ext.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/payjoin/src/core/uri/url_ext.rs b/payjoin/src/core/uri/url_ext.rs index e263c1819..36b3d79fe 100644 --- a/payjoin/src/core/uri/url_ext.rs +++ b/payjoin/src/core/uri/url_ext.rs @@ -127,7 +127,7 @@ fn set_param(url: &mut Url, prefix: &str, param: &str) { let fragment = url.fragment().unwrap_or(""); let mut fragment = fragment.to_string(); if let Some(start) = fragment.find(prefix) { - let end = fragment[start..].find('+').map_or(fragment.len(), |i| start + i); + let end = fragment[start..].find('-').map_or(fragment.len(), |i| start + i + 1); fragment.replace_range(start..end, ""); if fragment.ends_with('+') { fragment.pop(); From 76c5cb1dcbc08c7b109004e87fa8b4ce460f480e Mon Sep 17 00:00:00 2001 From: Yuval Kogman Date: Sat, 5 Jul 2025 13:34:04 +0200 Subject: [PATCH 3/5] Use `-` as fragment delimiter instead of `+` Although RFC 3986 (URIs) does not assign any special meaning to `+`, in fragment parameters or in general, RFC 1866 (HTML 2.0) section 7.5 uses it as a delimiter for keywords in query parameters. As a result some URI libraries interpret `+` in URIs as ` `, even in fragment parameters. Although not insurmountable (such transformation BIP 77 URIs is reversible because ` ` is not used and `+` was only used for fragment parameter delimitation) this presents friction and is in general confusion, so to improve compatibility with such libraries `-` is now used instead. It has no reserved meaning as a sub-delimiter. For the time being when parsing both `+` and `-` will be accepted, but only `-` will be used when encoding fragment parameters. --- payjoin/src/core/uri/url_ext.rs | 54 +++++++++++++++++++++++++++++++-- 1 file changed, 51 insertions(+), 3 deletions(-) diff --git a/payjoin/src/core/uri/url_ext.rs b/payjoin/src/core/uri/url_ext.rs index 36b3d79fe..e338e4a94 100644 --- a/payjoin/src/core/uri/url_ext.rs +++ b/payjoin/src/core/uri/url_ext.rs @@ -114,7 +114,24 @@ where F: Fn(&str) -> Option, { if let Some(fragment) = url.fragment() { - for param in fragment.split('+') { + let mut delim = '-'; + + // For backwards compatibility, also accept `+` as a + // fragment parameter delimiter. This was previously + // specified, but may be interpreted as ` ` by some + // URI parsoing libraries. Therefore if `-` is missing, + // assume the URI was generated following the older + // version of the spec. + if !fragment.contains(delim) { + delim = '+'; + } + + // The spec says these MUST be ordered lexicographically. + // However, this was a late spec change, and only matters + // for privacy reasons (fingerprinting implementations). + // To maintain compatibility, we don't care about the order + // of the parameters. + for param in fragment.split(delim) { if param.starts_with(prefix) { return parse(param); } @@ -126,16 +143,21 @@ where fn set_param(url: &mut Url, prefix: &str, param: &str) { let fragment = url.fragment().unwrap_or(""); let mut fragment = fragment.to_string(); + + if !fragment.contains('-') { + fragment = fragment.replace("+", "-"); + } + if let Some(start) = fragment.find(prefix) { let end = fragment[start..].find('-').map_or(fragment.len(), |i| start + i + 1); fragment.replace_range(start..end, ""); - if fragment.ends_with('+') { + if fragment.ends_with('-') { fragment.pop(); } } if !fragment.is_empty() { - fragment.push('+'); + fragment.push('-'); } fragment.push_str(param); @@ -368,4 +390,30 @@ mod tests { } Ok(()) } + + #[test] + fn test_fragment_delimeter_backwards_compatibility() { + // ensure + is still accepted as a delimiter + let uri = "bitcoin:12c6DSiU4Rq3P4ZxziKxzrL5LmMBrzjrJX?amount=0.01\ + &pjos=0&pj=HTTPS://EXAMPLE.COM/\ + %23EX1C4UC6ES+OH1QYPM5JXYNS754Y4R45QWE336QFX6ZR8DQGVQCULVZTV20TFVEYDMFQC"; + let pjuri = Uri::try_from(uri).unwrap().assume_checked().check_pj_supported().unwrap(); + + let mut endpoint = pjuri.extras.endpoint().clone(); + assert!(endpoint.ohttp().is_ok()); + assert!(endpoint.exp().is_ok()); + + // Before setting the delimiter should be preserved + assert_eq!( + endpoint.fragment(), + Some("EX1C4UC6ES+OH1QYPM5JXYNS754Y4R45QWE336QFX6ZR8DQGVQCULVZTV20TFVEYDMFQC") + ); + + // Upon setting any value, the delimiter should be normalized to `-` + endpoint.set_exp(pjuri.extras.endpoint.exp().unwrap()); + assert_eq!( + endpoint.fragment(), + Some("OH1QYPM5JXYNS754Y4R45QWE336QFX6ZR8DQGVQCULVZTV20TFVEYDMFQC-EX1C4UC6ES") + ); + } } From 2f881ce991ff5b1375af280c1a895c19100a69b1 Mon Sep 17 00:00:00 2001 From: Yuval Kogman Date: Sat, 5 Jul 2025 14:00:51 +0200 Subject: [PATCH 4/5] Order fragment parameters lexicographically Previously `set_param` would did not preserve order, but the way that `set_param` was called ended up setting the RK, OH and EX fragment parameters in reverse lexicographical order. To avoid any privacy leaks from URI construction (revealing the specific software the receiver is using) the spec now requires fragment parameters to be ordered lexicographically, so `set_param` now ensures this. --- payjoin/src/core/uri/url_ext.rs | 77 ++++++++++++++++++++++++--------- 1 file changed, 57 insertions(+), 20 deletions(-) diff --git a/payjoin/src/core/uri/url_ext.rs b/payjoin/src/core/uri/url_ext.rs index e338e4a94..e43694815 100644 --- a/payjoin/src/core/uri/url_ext.rs +++ b/payjoin/src/core/uri/url_ext.rs @@ -1,3 +1,4 @@ +use std::collections::BTreeMap; use std::str::FromStr; use bitcoin::bech32::Hrp; @@ -43,7 +44,6 @@ impl UrlExt for Url { set_param( self, - "RK1", &crate::bech32::nochecksum::encode(rk_hrp, &pubkey.to_compressed_bytes()) .expect("encoding compressed pubkey bytes should never fail"), ) @@ -57,7 +57,7 @@ impl UrlExt for Url { } /// Set the ohttp parameter in the URL fragment - fn set_ohttp(&mut self, ohttp: OhttpKeys) { set_param(self, "OH1", &ohttp.to_string()) } + fn set_ohttp(&mut self, ohttp: OhttpKeys) { set_param(self, &ohttp.to_string()) } /// Retrieve the exp parameter from the URL fragment fn exp(&self) -> Result { @@ -94,7 +94,7 @@ impl UrlExt for Url { let exp_str = crate::bech32::nochecksum::encode(ex_hrp, &buf) .expect("encoding u32 timestamp should never fail"); - set_param(self, "EX1", &exp_str) + set_param(self, &exp_str) } } @@ -140,28 +140,41 @@ where None } -fn set_param(url: &mut Url, prefix: &str, param: &str) { +/// Set a URL fragment parameter, inserting it or replacing it depending on +/// whether a parameter with the same bech32 HRP is already present. +/// +/// Parameters are sorted lexicographically by prefix. +fn set_param(url: &mut Url, new_param: &str) { let fragment = url.fragment().unwrap_or(""); - let mut fragment = fragment.to_string(); - if !fragment.contains('-') { - fragment = fragment.replace("+", "-"); + // See above for `-` vs `+` backwards compatibility + let mut delim = '-'; + if !fragment.contains(delim) { + delim = '+'; } - if let Some(start) = fragment.find(prefix) { - let end = fragment[start..].find('-').map_or(fragment.len(), |i| start + i + 1); - fragment.replace_range(start..end, ""); - if fragment.ends_with('-') { - fragment.pop(); - } - } - - if !fragment.is_empty() { - fragment.push('-'); + // In case of an invalid fragment parameter the following will still attempt + // to retain the existing data + let mut params = fragment + .split(delim) + .filter(|param| !param.is_empty()) + .map(|param| { + let key = param.split('1').next().unwrap_or(param); + (key, param) + }) + .collect::>(); + + // TODO: change param to Option(&str) to allow deletion? + let key = new_param.split('1').next().unwrap_or(new_param); + params.insert(key, new_param); + + if params.is_empty() { + url.set_fragment(None) + } else { + // Can we avoid intermediate allocation of Vec, intersperse() exists but not in MSRV + let fragment = params.values().copied().collect::>().join("-"); + url.set_fragment(Some(&fragment)); } - fragment.push_str(param); - - url.set_fragment(if fragment.is_empty() { None } else { Some(&fragment) }); } #[derive(Debug)] @@ -411,9 +424,33 @@ mod tests { // Upon setting any value, the delimiter should be normalized to `-` endpoint.set_exp(pjuri.extras.endpoint.exp().unwrap()); + assert_eq!( + endpoint.fragment(), + Some("EX1C4UC6ES-OH1QYPM5JXYNS754Y4R45QWE336QFX6ZR8DQGVQCULVZTV20TFVEYDMFQC") + ); + } + + #[test] + fn test_fragment_lexicographical_order() { + let uri = "bitcoin:12c6DSiU4Rq3P4ZxziKxzrL5LmMBrzjrJX?amount=0.01\ + &pjos=0&pj=HTTPS://EXAMPLE.COM/\ + %23OH1QYPM5JXYNS754Y4R45QWE336QFX6ZR8DQGVQCULVZTV20TFVEYDMFQC-EX1C4UC6ES"; + let pjuri = Uri::try_from(uri).unwrap().assume_checked().check_pj_supported().unwrap(); + + let mut endpoint = pjuri.extras.endpoint().clone(); + assert!(endpoint.ohttp().is_ok()); + assert!(endpoint.exp().is_ok()); + assert_eq!( endpoint.fragment(), Some("OH1QYPM5JXYNS754Y4R45QWE336QFX6ZR8DQGVQCULVZTV20TFVEYDMFQC-EX1C4UC6ES") ); + + // Upon setting any value, the order should be normalized to lexicographical + endpoint.set_exp(pjuri.extras.endpoint.exp().unwrap()); + assert_eq!( + endpoint.fragment(), + Some("EX1C4UC6ES-OH1QYPM5JXYNS754Y4R45QWE336QFX6ZR8DQGVQCULVZTV20TFVEYDMFQC") + ); } } From 0eb74b9e527441d065344706a0c3b341bb64357b Mon Sep 17 00:00:00 2001 From: Yuval Kogman Date: Sun, 6 Jul 2025 18:52:31 +0200 Subject: [PATCH 5/5] Stricter parsing of fragment parameters Ambiguity in the fragment parameter delimiter or any invalid characters are no longer allowed. The HRPs EX, OH, and RK are within the uppercase bech32 character set. Only this character set along with the HRP delimiter `1` are now allowed, with either `+` or `-` as a delimiter (but not both). --- payjoin/src/core/ohttp.rs | 2 +- payjoin/src/core/uri/url_ext.rs | 128 +++++++++++++++++++++++--------- 2 files changed, 95 insertions(+), 35 deletions(-) diff --git a/payjoin/src/core/ohttp.rs b/payjoin/src/core/ohttp.rs index ff7a11831..13200a9f7 100644 --- a/payjoin/src/core/ohttp.rs +++ b/payjoin/src/core/ohttp.rs @@ -322,7 +322,7 @@ impl std::fmt::Display for ParseOhttpKeysError { match self { ParseOhttpKeysError::InvalidFormat => write!(f, "Invalid format"), ParseOhttpKeysError::InvalidPublicKey => write!(f, "Invalid public key"), - ParseOhttpKeysError::DecodeBech32(e) => write!(f, "Failed to decode base64: {e}"), + ParseOhttpKeysError::DecodeBech32(e) => write!(f, "Failed to decode bech32: {e}"), ParseOhttpKeysError::DecodeKeyConfig(e) => write!(f, "Failed to decode KeyConfig: {e}"), } } diff --git a/payjoin/src/core/uri/url_ext.rs b/payjoin/src/core/uri/url_ext.rs index e43694815..06199b465 100644 --- a/payjoin/src/core/uri/url_ext.rs +++ b/payjoin/src/core/uri/url_ext.rs @@ -23,10 +23,11 @@ pub(crate) trait UrlExt { impl UrlExt for Url { /// Retrieve the receiver's public key from the URL fragment fn receiver_pubkey(&self) -> Result { - let value = get_param(self, "RK1", |v| Some(v.to_owned())) + let value = get_param(self, "RK1") + .map_err(ParseReceiverPubkeyParamError::InvalidFragment)? .ok_or(ParseReceiverPubkeyParamError::MissingPubkey)?; - let (hrp, bytes) = crate::bech32::nochecksum::decode(&value) + let (hrp, bytes) = crate::bech32::nochecksum::decode(value) .map_err(ParseReceiverPubkeyParamError::DecodeBech32)?; let rk_hrp: Hrp = Hrp::parse("RK").unwrap(); @@ -51,9 +52,10 @@ impl UrlExt for Url { /// Retrieve the ohttp parameter from the URL fragment fn ohttp(&self) -> Result { - let value = get_param(self, "OH1", |v| Some(v.to_owned())) + let value = get_param(self, "OH1") + .map_err(ParseOhttpKeysParamError::InvalidFragment)? .ok_or(ParseOhttpKeysParamError::MissingOhttpKeys)?; - OhttpKeys::from_str(&value).map_err(ParseOhttpKeysParamError::InvalidOhttpKeys) + OhttpKeys::from_str(value).map_err(ParseOhttpKeysParamError::InvalidOhttpKeys) } /// Set the ohttp parameter in the URL fragment @@ -61,11 +63,12 @@ impl UrlExt for Url { /// Retrieve the exp parameter from the URL fragment fn exp(&self) -> Result { - let value = - get_param(self, "EX1", |v| Some(v.to_owned())).ok_or(ParseExpParamError::MissingExp)?; + let value = get_param(self, "EX1") + .map_err(ParseExpParamError::InvalidFragment)? + .ok_or(ParseExpParamError::MissingExp)?; let (hrp, bytes) = - crate::bech32::nochecksum::decode(&value).map_err(ParseExpParamError::DecodeBech32)?; + crate::bech32::nochecksum::decode(value).map_err(ParseExpParamError::DecodeBech32)?; let ex_hrp: Hrp = Hrp::parse("EX").unwrap(); if hrp != ex_hrp { @@ -109,22 +112,63 @@ pub fn parse_with_fragment(endpoint: &str) -> Result { Ok(url) } -fn get_param(url: &Url, prefix: &str, parse: F) -> Option -where - F: Fn(&str) -> Option, -{ - if let Some(fragment) = url.fragment() { - let mut delim = '-'; - - // For backwards compatibility, also accept `+` as a - // fragment parameter delimiter. This was previously - // specified, but may be interpreted as ` ` by some - // URI parsoing libraries. Therefore if `-` is missing, - // assume the URI was generated following the older - // version of the spec. - if !fragment.contains(delim) { - delim = '+'; +#[derive(Debug)] +pub(crate) enum ParseFragmentError { + InvalidChar(char), + AmbiguousDelimiter, +} + +impl std::error::Error for ParseFragmentError { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { None } +} + +impl std::fmt::Display for ParseFragmentError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + use ParseFragmentError::*; + + match &self { + InvalidChar(c) => write!(f, "invalid character: {c} (must be uppercase)"), + AmbiguousDelimiter => write!(f, "ambiguous fragment delimiter (both + and - found)"), + } + } +} + +fn check_fragment_delimiter(fragment: &str) -> Result { + // For backwards compatibility, also accept `+` as a + // fragment parameter delimiter. This was previously + // specified, but may be interpreted as ` ` by some + // URI parsoing libraries. Therefore if `-` is missing, + // assume the URI was generated following the older + // version of the spec. + + let has_dash = fragment.contains('-'); + let has_plus = fragment.contains('+'); + + // Even though fragment is a &str, it should be ascii so bytes() correspond + // to chars(), except that it's easier to check that they are in range + for c in fragment.bytes() { + // These character ranges are more permissive than uppercase bech32, but + // also more restrictive than bech32 in general since lowercase is not + // allowed + if !(b'0'..b'9' + 1).contains(&c) + && !(b'A'..b'Z' + 1).contains(&c) + && c != b'-' + && c != b'+' + { + return Err(ParseFragmentError::InvalidChar(c.into())); } + } + + match (has_dash, has_plus) { + (true, true) => Err(ParseFragmentError::AmbiguousDelimiter), + (false, true) => Ok('+'), + _ => Ok('-'), + } +} + +fn get_param<'a>(url: &'a Url, prefix: &str) -> Result, ParseFragmentError> { + if let Some(fragment) = url.fragment() { + let delim = check_fragment_delimiter(fragment)?; // The spec says these MUST be ordered lexicographically. // However, this was a late spec change, and only matters @@ -133,11 +177,11 @@ where // of the parameters. for param in fragment.split(delim) { if param.starts_with(prefix) { - return parse(param); + return Ok(Some(param)); } } } - None + Ok(None) } /// Set a URL fragment parameter, inserting it or replacing it depending on @@ -146,12 +190,8 @@ where /// Parameters are sorted lexicographically by prefix. fn set_param(url: &mut Url, new_param: &str) { let fragment = url.fragment().unwrap_or(""); - - // See above for `-` vs `+` backwards compatibility - let mut delim = '-'; - if !fragment.contains(delim) { - delim = '+'; - } + let delim = check_fragment_delimiter(fragment) + .expect("set_param must be called on a URL with a valid fragment"); // In case of an invalid fragment parameter the following will still attempt // to retain the existing data @@ -181,6 +221,7 @@ fn set_param(url: &mut Url, new_param: &str) { pub(crate) enum ParseOhttpKeysParamError { MissingOhttpKeys, InvalidOhttpKeys(crate::ohttp::ParseOhttpKeysError), + InvalidFragment(ParseFragmentError), } impl std::fmt::Display for ParseOhttpKeysParamError { @@ -190,6 +231,7 @@ impl std::fmt::Display for ParseOhttpKeysParamError { match &self { MissingOhttpKeys => write!(f, "ohttp keys are missing"), InvalidOhttpKeys(o) => write!(f, "invalid ohttp keys: {o}"), + InvalidFragment(e) => write!(f, "invalid URL fragment: {e}"), } } } @@ -200,6 +242,7 @@ pub(crate) enum ParseExpParamError { InvalidHrp(bitcoin::bech32::Hrp), DecodeBech32(bitcoin::bech32::primitives::decode::CheckedHrpstringError), InvalidExp(bitcoin::consensus::encode::Error), + InvalidFragment(ParseFragmentError), } impl std::fmt::Display for ParseExpParamError { @@ -212,17 +255,18 @@ impl std::fmt::Display for ParseExpParamError { DecodeBech32(d) => write!(f, "exp is not valid bech32: {d}"), InvalidExp(i) => write!(f, "exp param does not contain a bitcoin consensus encoded u32: {i}"), + InvalidFragment(e) => write!(f, "invalid URL fragment: {e}"), } } } -#[cfg(feature = "v2")] #[derive(Debug)] pub(crate) enum ParseReceiverPubkeyParamError { MissingPubkey, InvalidHrp(bitcoin::bech32::Hrp), DecodeBech32(bitcoin::bech32::primitives::decode::CheckedHrpstringError), InvalidPubkey(crate::hpke::HpkeError), + InvalidFragment(ParseFragmentError), } impl std::fmt::Display for ParseReceiverPubkeyParamError { @@ -235,6 +279,7 @@ impl std::fmt::Display for ParseReceiverPubkeyParamError { DecodeBech32(e) => write!(f, "receiver public is not valid base64: {e}"), InvalidPubkey(e) => write!(f, "receiver public key does not represent a valid pubkey: {e}"), + InvalidFragment(e) => write!(f, "invalid URL fragment: {e}"), } } } @@ -248,6 +293,7 @@ impl std::error::Error for ParseReceiverPubkeyParamError { InvalidHrp(_) => None, DecodeBech32(error) => Some(error), InvalidPubkey(error) => Some(error), + InvalidFragment(error) => Some(error), } } } @@ -287,7 +333,7 @@ mod tests { .unwrap(); assert!(matches!( invalid_ohttp_url.ohttp(), - Err(ParseOhttpKeysParamError::InvalidOhttpKeys(_)) + Err(ParseOhttpKeysParamError::InvalidFragment(_)) )); } @@ -308,9 +354,16 @@ mod tests { let missing_exp_url = EXAMPLE_URL.clone(); assert!(matches!(missing_exp_url.exp(), Err(ParseExpParamError::MissingExp))); - let invalid_bech32_exp_url = + let invalid_fragment_exp_url = Url::parse("http://example.com?pj=https://test-payjoin-url#EX1invalid_bech_32") .unwrap(); + assert!(matches!( + invalid_fragment_exp_url.exp(), + Err(ParseExpParamError::InvalidFragment(_)) + )); + + let invalid_bech32_exp_url = + Url::parse("http://example.com?pj=https://test-payjoin-url#EX1INVALIDBECH32").unwrap(); assert!(matches!(invalid_bech32_exp_url.exp(), Err(ParseExpParamError::DecodeBech32(_)))); // Since the HRP is everything to the left of the right-most separator, the invalid url in @@ -333,9 +386,16 @@ mod tests { Err(ParseReceiverPubkeyParamError::MissingPubkey) )); - let invalid_bech32_receiver_pubkey_url = + let invalid_fragment_receiver_pubkey_url = Url::parse("http://example.com?pj=https://test-payjoin-url#RK1invalid_bech_32") .unwrap(); + assert!(matches!( + invalid_fragment_receiver_pubkey_url.receiver_pubkey(), + Err(ParseReceiverPubkeyParamError::InvalidFragment(_)) + )); + + let invalid_bech32_receiver_pubkey_url = + Url::parse("http://example.com?pj=https://test-payjoin-url#RK1INVALIDBECH32").unwrap(); assert!(matches!( invalid_bech32_receiver_pubkey_url.receiver_pubkey(), Err(ParseReceiverPubkeyParamError::DecodeBech32(_))