From 36663c53f2faabc2dabbe4b0c6a56959c0244889 Mon Sep 17 00:00:00 2001 From: user Date: Wed, 3 Sep 2025 15:27:19 -0400 Subject: [PATCH 1/3] Move v2 and v1 uri features into the correct modules The uri tests were looking at bip21 uris with v1 or v2 specific features and failing when the features were locked to the opposite feature respectively. I have moved the relevant tests to be in the correct module so that they only compile within the correct feature. --- payjoin/src/core/send/error.rs | 9 +- payjoin/src/core/uri/mod.rs | 154 +--------------------------- payjoin/src/core/uri/v1.rs | 178 +++++++++++++++++++++++++++++++++ payjoin/src/core/uri/v2.rs | 25 ++--- 4 files changed, 190 insertions(+), 176 deletions(-) diff --git a/payjoin/src/core/send/error.rs b/payjoin/src/core/send/error.rs index 7e70286b5..6e1fb8d32 100644 --- a/payjoin/src/core/send/error.rs +++ b/payjoin/src/core/send/error.rs @@ -403,12 +403,11 @@ impl WellKnownError { #[cfg(test)] mod tests { - use serde_json::json; - - use super::*; - #[test] + #[cfg(feature = "v1")] fn test_parse_json() { + use super::*; + let known_str_error = r#"{"errorCode":"version-unsupported", "message":"custom message here", "supported": [1, 2]}"#; match ResponseError::parse(known_str_error) { ResponseError::WellKnown(e) => { @@ -426,7 +425,7 @@ mod tests { ResponseError::parse(unrecognized_error), ResponseError::Unrecognized { .. } )); - let invalid_json_error = json!({ + let invalid_json_error = serde_json::json!({ "err": "random", "message": "This version of payjoin is not supported." }); diff --git a/payjoin/src/core/uri/mod.rs b/payjoin/src/core/uri/mod.rs index 5c151dbdd..9c7158987 100644 --- a/payjoin/src/core/uri/mod.rs +++ b/payjoin/src/core/uri/mod.rs @@ -267,12 +267,6 @@ mod tests { assert!(Uri::try_from(uri).is_err(), "pj is not a valid url"); } - #[test] - fn test_missing_amount() { - let uri = "bitcoin:12c6DSiU4Rq3P4ZxziKxzrL5LmMBrzjrJX?pj=https://testnet.demo.btcpayserver.org/BTC/pj"; - assert!(Uri::try_from(uri).is_ok(), "missing amount should be ok"); - } - #[test] fn test_unencrypted() { let uri = "bitcoin:12c6DSiU4Rq3P4ZxziKxzrL5LmMBrzjrJX?amount=1&pj=http://example.com"; @@ -282,29 +276,6 @@ mod tests { assert!(Uri::try_from(uri).is_err(), "unencrypted connection"); } - #[test] - fn test_valid_uris() { - let https = "https://example.com"; - let onion = "http://vjdpwgybvubne5hda6v4c5iaeeevhge6jvo3w2cl6eocbwwvwxp7b7qd.onion"; - - let base58 = "bitcoin:12c6DSiU4Rq3P4ZxziKxzrL5LmMBrzjrJX"; - let bech32_upper = "BITCOIN:TB1Q6D3A2W975YNY0ASUVD9A67NER4NKS58FF0Q8G4"; - let bech32_lower = "bitcoin:tb1q6d3a2w975yny0asuvd9a67ner4nks58ff0q8g4"; - - for address in [base58, bech32_upper, bech32_lower].iter() { - for pj in [https, onion].iter() { - let uri_with_amount = format!("{address}?amount=1&pj={pj}"); - assert!(Uri::try_from(uri_with_amount).is_ok()); - - let uri_without_amount = format!("{address}?pj={pj}"); - assert!(Uri::try_from(uri_without_amount).is_ok()); - - let uri_shuffled_params = format!("{address}?pj={pj}&amount=1"); - assert!(Uri::try_from(uri_shuffled_params).is_ok()); - } - } - } - #[test] fn test_unsupported() { assert!( @@ -316,26 +287,10 @@ mod tests { ); } - #[test] - fn test_supported() { - assert!( - Uri::try_from( - "bitcoin:12c6DSiU4Rq3P4ZxziKxzrL5LmMBrzjrJX?amount=0.01\ - &pjos=0&pj=HTTPS://EXAMPLE.COM/\ - %23OH1QYPM5JXYNS754Y4R45QWE336QFX6ZR8DQGVQCULVZTV20TFVEYDMFQC" - ) - .unwrap() - .extras - .pj_is_supported(), - "Uri expected a success with a well formatted pj extras, but it failed" - ); - } - #[test] fn test_pj_param_unknown() { use bitcoin_uri::de::DeserializationState as _; - let uri = "bitcoin:12c6DSiU4Rq3P4ZxziKxzrL5LmMBrzjrJX?pjos=1&pj=HTTPS://EXAMPLE.COM/\ - %23OH1QYPM5JXYNS754Y4R45QWE336QFX6ZR8DQGVQCULVZTV20TFVEYDMFQC"; + let uri = "bitcoin:12c6DSiU4Rq3P4ZxziKxzrL5LmMBrzjrJX?pjos=1&pj=HTTPS://EXAMPLE.COM/TXJCGKTKXLUUZ%23EX1C4UC6ES-OH1QYPM5JXYNS754Y4R45QWE336QFX6ZR8DQGVQCULVZTV20TFVEYDMFQC-RK1Q0DJS3VVDXWQQTLQ8022QGXSX7ML9PHZ6EDSF6AKEWQG758JPS2EV"; let pjuri = Uri::try_from(uri).unwrap().assume_checked().check_pj_supported().unwrap(); let serialized_params = pjuri.extras.serialize_params(); let pjos_key = serialized_params.clone().next().expect("Missing pjos key").0; @@ -350,111 +305,4 @@ mod tests { "An unknown_param should not match 'pj' or 'pjos'" ); } - - #[test] - fn test_pj_duplicate_params() { - let uri = - "bitcoin:12c6DSiU4Rq3P4ZxziKxzrL5LmMBrzjrJX?pjos=1&pjos=1&pj=HTTPS://EXAMPLE.COM/\ - %23OH1QYPM5JXYNS754Y4R45QWE336QFX6ZR8DQGVQCULVZTV20TFVEYDMFQC"; - let pjuri = Uri::try_from(uri); - assert!(matches!( - pjuri, - Err(bitcoin_uri::de::Error::Extras(PjParseError( - InternalPjParseError::DuplicateParams("pjos") - ))) - )); - let uri = - "bitcoin:12c6DSiU4Rq3P4ZxziKxzrL5LmMBrzjrJX?pjos=1&pj=HTTPS://EXAMPLE.COM/\ - %23OH1QYPM5JXYNS754Y4R45QWE336QFX6ZR8DQGVQCULVZTV20TFVEYDMFQC&pj=HTTPS://EXAMPLE.COM/\ - %23OH1QYPM5JXYNS754Y4R45QWE336QFX6ZR8DQGVQCULVZTV20TFVEYDMFQC"; - let pjuri = Uri::try_from(uri); - assert!(matches!( - pjuri, - Err(bitcoin_uri::de::Error::Extras(PjParseError( - InternalPjParseError::DuplicateParams("pj") - ))) - )); - } - - #[test] - fn test_serialize_pjos() { - let uri = "bitcoin:12c6DSiU4Rq3P4ZxziKxzrL5LmMBrzjrJX?pj=HTTPS://EXAMPLE.COM/%23OH1QYPM5JXYNS754Y4R45QWE336QFX6ZR8DQGVQCULVZTV20TFVEYDMFQC"; - let expected_is_disabled = "pjos=0"; - let expected_is_enabled = "pjos=1"; - let mut pjuri = Uri::try_from(uri) - .expect("Invalid uri") - .assume_checked() - .check_pj_supported() - .expect("Could not parse pj extras"); - - pjuri.extras.output_substitution = OutputSubstitution::Disabled; - assert!( - pjuri.to_string().contains(expected_is_disabled), - "Pj uri should contain param: {expected_is_disabled}, but it did not" - ); - - pjuri.extras.output_substitution = OutputSubstitution::Enabled; - assert!( - !pjuri.to_string().contains(expected_is_enabled), - "Pj uri should elide param: {expected_is_enabled}, but it did not" - ); - } - - #[test] - fn test_deserialize_pjos() { - // pjos=0 should disable output substitution - let uri = "bitcoin:12c6DSiU4Rq3P4ZxziKxzrL5LmMBrzjrJX?pj=https://example.com&pjos=0"; - let parsed = Uri::try_from(uri).unwrap(); - match parsed.extras { - MaybePayjoinExtras::Supported(extras) => - assert_eq!(extras.output_substitution, OutputSubstitution::Disabled), - _ => panic!("Expected Supported PayjoinExtras"), - } - - // pjos=1 should allow output substitution - let uri = "bitcoin:12c6DSiU4Rq3P4ZxziKxzrL5LmMBrzjrJX?pj=https://example.com&pjos=1"; - let parsed = Uri::try_from(uri).unwrap(); - match parsed.extras { - MaybePayjoinExtras::Supported(extras) => - assert_eq!(extras.output_substitution, OutputSubstitution::Enabled), - _ => panic!("Expected Supported PayjoinExtras"), - } - - // Elided pjos=1 should allow output substitution - let uri = "bitcoin:12c6DSiU4Rq3P4ZxziKxzrL5LmMBrzjrJX?pj=https://example.com"; - let parsed = Uri::try_from(uri).unwrap(); - match parsed.extras { - MaybePayjoinExtras::Supported(extras) => - assert_eq!(extras.output_substitution, OutputSubstitution::Enabled), - _ => panic!("Expected Supported PayjoinExtras"), - } - } - - /// Test that rejects HTTP URLs that are not onion addresses - #[test] - fn test_http_non_onion_rejected() { - // HTTP to regular domain should be rejected - let url = "http://example.com"; - let result = PjParam::parse(url); - assert!( - matches!(result, Err(PjParseError(InternalPjParseError::UnsecureEndpoint))), - "Expected UnsecureEndpoint error for HTTP to non-onion domain" - ); - - // HTTPS to subdomain should be accepted - let url = "https://example.com"; - let result = PjParam::parse(url); - assert!( - matches!(result, Ok(PjParam::V1(_))), - "Expected PjParam::V1 for HTTPS to non-onion domain without fragment" - ); - - // HTTP to domain ending in .onion should be accepted - let url = "http://example.onion"; - let result = PjParam::parse(url); - assert!( - matches!(result, Ok(PjParam::V1(_))), - "Expected PjParam::V1 for HTTP to onion domain without fragment" - ); - } } diff --git a/payjoin/src/core/uri/v1.rs b/payjoin/src/core/uri/v1.rs index 702a671fb..95ffdd212 100644 --- a/payjoin/src/core/uri/v1.rs +++ b/payjoin/src/core/uri/v1.rs @@ -31,3 +31,181 @@ impl std::fmt::Display for PjParam { self.0.fmt(f) } } + +#[cfg(test)] +mod tests { + use std::convert::TryFrom; + + use payjoin_test_utils::BoxError; + + use super::*; + // use crate::uri::v2::PjParseError; + use crate::uri::MaybePayjoinExtras; + use crate::{OutputSubstitution, PjParam, Uri, UriExt}; + + #[test] + fn test_missing_amount() { + let uri = "bitcoin:12c6DSiU4Rq3P4ZxziKxzrL5LmMBrzjrJX?pj=https://testnet.demo.btcpayserver.org/BTC/pj"; + assert!(Uri::try_from(uri).is_ok(), "missing amount should be ok"); + } + + #[test] + fn test_valid_uris() { + let https = "https://example.com"; + let onion = "http://vjdpwgybvubne5hda6v4c5iaeeevhge6jvo3w2cl6eocbwwvwxp7b7qd.onion"; + + let base58 = "bitcoin:12c6DSiU4Rq3P4ZxziKxzrL5LmMBrzjrJX"; + let bech32_upper = "BITCOIN:TB1Q6D3A2W975YNY0ASUVD9A67NER4NKS58FF0Q8G4"; + let bech32_lower = "bitcoin:tb1q6d3a2w975yny0asuvd9a67ner4nks58ff0q8g4"; + + for address in [base58, bech32_upper, bech32_lower].iter() { + for pj in [https, onion].iter() { + let uri_with_amount = format!("{address}?amount=1&pj={pj}"); + assert!(Uri::try_from(uri_with_amount).is_ok()); + + let uri_without_amount = format!("{address}?pj={pj}"); + assert!(Uri::try_from(uri_without_amount).is_ok()); + + let uri_shuffled_params = format!("{address}?pj={pj}&amount=1"); + assert!(Uri::try_from(uri_shuffled_params).is_ok()); + } + } + } + + #[test] + fn test_supported() { + assert!( + Uri::try_from( + "bitcoin:12c6DSiU4Rq3P4ZxziKxzrL5LmMBrzjrJX?amount=0.01\ + &pjos=0&pj=HTTPS://EXAMPLE.COM/\ + %23OH1QYPM5JXYNS754Y4R45QWE336QFX6ZR8DQGVQCULVZTV20TFVEYDMFQC" + ) + .unwrap() + .extras + .pj_is_supported(), + "Uri expected a success with a well formatted pj extras, but it failed" + ); + } + + #[test] + fn test_v1_failed_url_fragment() -> Result<(), BoxError> { + let uri = "bitcoin:12c6DSiU4Rq3P4ZxziKxzrL5LmMBrzjrJX?amount=0.01\ + &pjos=0&pj=HTTPS://EXAMPLE.COM/missing_short_id\ + %23oh1qypm5jxyns754y4r45qwe336qfx6zr8dqgvqculvztv20tfveydmfqc"; + let extras = Uri::try_from(uri).unwrap().extras; + match extras { + crate::uri::MaybePayjoinExtras::Supported(extras) => { + assert!(matches!(extras.pj_param, crate::uri::PjParam::V1(_))); + } + _ => panic!("Expected v1 pjparam"), + } + Ok(()) + } + + #[test] + fn test_pj_duplicate_params() { + let uri = + "bitcoin:12c6DSiU4Rq3P4ZxziKxzrL5LmMBrzjrJX?pjos=1&pjos=1&pj=HTTPS://EXAMPLE.COM/\ + %23OH1QYPM5JXYNS754Y4R45QWE336QFX6ZR8DQGVQCULVZTV20TFVEYDMFQC"; + let pjuri = Uri::try_from(uri); + assert!(matches!( + pjuri, + Err(bitcoin_uri::de::Error::Extras(PjParseError( + InternalPjParseError::DuplicateParams("pjos") + ))) + )); + let uri = + "bitcoin:12c6DSiU4Rq3P4ZxziKxzrL5LmMBrzjrJX?pjos=1&pj=HTTPS://EXAMPLE.COM/\ + %23OH1QYPM5JXYNS754Y4R45QWE336QFX6ZR8DQGVQCULVZTV20TFVEYDMFQC&pj=HTTPS://EXAMPLE.COM/\ + %23OH1QYPM5JXYNS754Y4R45QWE336QFX6ZR8DQGVQCULVZTV20TFVEYDMFQC"; + let pjuri = Uri::try_from(uri); + assert!(matches!( + pjuri, + Err(bitcoin_uri::de::Error::Extras(PjParseError( + InternalPjParseError::DuplicateParams("pj") + ))) + )); + } + + #[test] + fn test_serialize_pjos() { + let uri = "bitcoin:12c6DSiU4Rq3P4ZxziKxzrL5LmMBrzjrJX?pj=HTTPS://EXAMPLE.COM/%23OH1QYPM5JXYNS754Y4R45QWE336QFX6ZR8DQGVQCULVZTV20TFVEYDMFQC"; + let expected_is_disabled = "pjos=0"; + let expected_is_enabled = "pjos=1"; + let mut pjuri = Uri::try_from(uri) + .expect("Invalid uri") + .assume_checked() + .check_pj_supported() + .expect("Could not parse pj extras"); + + pjuri.extras.output_substitution = OutputSubstitution::Disabled; + assert!( + pjuri.to_string().contains(expected_is_disabled), + "Pj uri should contain param: {expected_is_disabled}, but it did not" + ); + + pjuri.extras.output_substitution = OutputSubstitution::Enabled; + assert!( + !pjuri.to_string().contains(expected_is_enabled), + "Pj uri should elide param: {expected_is_enabled}, but it did not" + ); + } + + #[test] + fn test_deserialize_pjos() { + // pjos=0 should disable output substitution + let uri = "bitcoin:12c6DSiU4Rq3P4ZxziKxzrL5LmMBrzjrJX?pj=https://example.com&pjos=0"; + let parsed = Uri::try_from(uri).unwrap(); + match parsed.extras { + MaybePayjoinExtras::Supported(extras) => + assert_eq!(extras.output_substitution, OutputSubstitution::Disabled), + _ => panic!("Expected Supported PayjoinExtras"), + } + + // pjos=1 should allow output substitution + let uri = "bitcoin:12c6DSiU4Rq3P4ZxziKxzrL5LmMBrzjrJX?pj=https://example.com&pjos=1"; + let parsed = Uri::try_from(uri).unwrap(); + match parsed.extras { + MaybePayjoinExtras::Supported(extras) => + assert_eq!(extras.output_substitution, OutputSubstitution::Enabled), + _ => panic!("Expected Supported PayjoinExtras"), + } + + // Elided pjos=1 should allow output substitution + let uri = "bitcoin:12c6DSiU4Rq3P4ZxziKxzrL5LmMBrzjrJX?pj=https://example.com"; + let parsed = Uri::try_from(uri).unwrap(); + match parsed.extras { + MaybePayjoinExtras::Supported(extras) => + assert_eq!(extras.output_substitution, OutputSubstitution::Enabled), + _ => panic!("Expected Supported PayjoinExtras"), + } + } + + /// Test that rejects HTTP URLs that are not onion addresses + #[test] + fn test_http_non_onion_rejected() { + // HTTP to regular domain should be rejected + let url = "http://example.com"; + let result = PjParam::parse(url); + assert!( + matches!(result, Err(PjParseError(InternalPjParseError::UnsecureEndpoint))), + "Expected UnsecureEndpoint error for HTTP to non-onion domain" + ); + + // HTTPS to subdomain should be accepted + let url = "https://example.com"; + let result = PjParam::parse(url); + assert!( + matches!(result, Ok(PjParam::V1(_))), + "Expected PjParam::V1 for HTTPS to non-onion domain without fragment" + ); + + // HTTP to domain ending in .onion should be accepted + let url = "http://example.onion"; + let result = PjParam::parse(url); + assert!( + matches!(result, Ok(PjParam::V1(_))), + "Expected PjParam::V1 for HTTP to onion domain without fragment" + ); + } +} diff --git a/payjoin/src/core/uri/v2.rs b/payjoin/src/core/uri/v2.rs index 2346bd816..257dbae30 100644 --- a/payjoin/src/core/uri/v2.rs +++ b/payjoin/src/core/uri/v2.rs @@ -545,15 +545,15 @@ mod tests { #[test] fn test_valid_v2_url_fragment_on_bip21() { let uri = "bitcoin:12c6DSiU4Rq3P4ZxziKxzrL5LmMBrzjrJX?amount=0.01\ - &pjos=0&pj=HTTPS://EXAMPLE.COM/\ - %23OH1QYPM5JXYNS754Y4R45QWE336QFX6ZR8DQGVQCULVZTV20TFVEYDMFQC"; + &pjos=0&pj=HTTPS://EXAMPLE.COM/TXJCGKTKXLUUZ\ + %23EX1C4UC6ES-OH1QYPM5JXYNS754Y4R45QWE336QFX6ZR8DQGVQCULVZTV20TFVEYDMFQC-RK1Q0DJS3VVDXWQQTLQ8022QGXSX7ML9PHZ6EDSF6AKEWQG758JPS2EV"; let pjuri = Uri::try_from(uri).unwrap().assume_checked().check_pj_supported().unwrap(); assert!(ohttp(&Url::parse(&pjuri.extras.endpoint()).expect("Could not parse url")).is_ok()); assert_eq!(format!("{pjuri}"), uri); let reordered = "bitcoin:12c6DSiU4Rq3P4ZxziKxzrL5LmMBrzjrJX?amount=0.01\ - &pj=HTTPS://EXAMPLE.COM/\ - %23OH1QYPM5JXYNS754Y4R45QWE336QFX6ZR8DQGVQCULVZTV20TFVEYDMFQC\ + &pj=HTTPS://EXAMPLE.COM/TXJCGKTKXLUUZ\ + %23EX1C4UC6ES-OH1QYPM5JXYNS754Y4R45QWE336QFX6ZR8DQGVQCULVZTV20TFVEYDMFQC-RK1Q0DJS3VVDXWQQTLQ8022QGXSX7ML9PHZ6EDSF6AKEWQG758JPS2EV\ &pjos=0"; let pjuri = Uri::try_from(reordered).unwrap().assume_checked().check_pj_supported().unwrap(); @@ -562,21 +562,10 @@ mod tests { } #[test] - fn test_failed_url_fragment() -> Result<(), BoxError> { - let uri = "bitcoin:12c6DSiU4Rq3P4ZxziKxzrL5LmMBrzjrJX?amount=0.01\ - &pjos=0&pj=HTTPS://EXAMPLE.COM/missing_short_id\ - %23oh1qypm5jxyns754y4r45qwe336qfx6zr8dqgvqculvztv20tfveydmfqc"; - let extras = Uri::try_from(uri).unwrap().extras; - match extras { - crate::uri::MaybePayjoinExtras::Supported(extras) => { - assert!(matches!(extras.pj_param, crate::uri::PjParam::V1(_))); - } - _ => panic!("Expected v1 pjparam"), - } - + fn test_v2_failed_url_fragment() -> Result<(), BoxError> { let uri = "bitcoin:12c6DSiU4Rq3P4ZxziKxzrL5LmMBrzjrJX?amount=0.01\ &pjos=0&pj=HTTPS://EXAMPLE.COM/TXJCGKTKXLUUZ\ - %23oh1qypm5jxyns754y4r45qwe336qfx6zr8dqgvqculvztv20tfveydmfqc"; + %23ex1c4uc6es-oh1qypm5jxyns754y4r45qwe336qfx6zr8dqgvqculvztv20tfveydmfqc-rk1q0djs3vvdxwqqtlq8022qgxsx7ml9phz6edsf6akewqg758jps2ev"; assert!(matches!( Uri::try_from(uri), Err(bitcoin_uri::de::Error::Extras(crate::uri::PjParseError( @@ -586,7 +575,7 @@ mod tests { let uri = "bitcoin:12c6DSiU4Rq3P4ZxziKxzrL5LmMBrzjrJX?amount=0.01\ &pjos=0&pj=HTTPS://EXAMPLE.COM/TXJCGKTKXLUUZ\ - %23OH1QYPM5JXYNS754Y4R45QWE336QFX6ZR8DQGVQCULVZTV20TFVEYDMFQc"; + %23EX1C4UC6ES-OH1QYPM5JXYNS754Y4R45QWE336QFX6ZR8DQGVQCULVZTV20TFVEYDMFQC-RK1Q0DJS3VVDXWQQTLQ8022QGXSX7ML9PHZ6EDSF6AKEWQG758JPS2Ev"; assert!(matches!( Uri::try_from(uri), Err(bitcoin_uri::de::Error::Extras(crate::uri::PjParseError( From 573ac854b62a744cf8bff5841810e84e44b5305c Mon Sep 17 00:00:00 2001 From: user Date: Wed, 3 Sep 2025 15:53:38 -0400 Subject: [PATCH 2/3] Feature gate integration tests This commit locks the integration tests down into their specific enabled versions. --- payjoin/tests/integration.rs | 476 ++++++++++++++++++----------------- 1 file changed, 250 insertions(+), 226 deletions(-) diff --git a/payjoin/tests/integration.rs b/payjoin/tests/integration.rs index 64529064b..c1f2363c4 100644 --- a/payjoin/tests/integration.rs +++ b/payjoin/tests/integration.rs @@ -1,38 +1,33 @@ mod integration { - use std::collections::HashMap; - use std::str::FromStr; - - use bitcoin::bech32::primitives::decode::CheckedHrpstring; - use bitcoin::bech32::NoChecksum; - use bitcoin::policy::DEFAULT_MIN_RELAY_TX_FEE; - use bitcoin::psbt::{Input as PsbtInput, Psbt}; - use bitcoin::{Amount, FeeRate, OutPoint, TxIn, TxOut, Weight}; - use payjoin::receive::v1::build_v1_pj_uri; - use payjoin::receive::InputPair; - use payjoin::{ImplementationError, OutputSubstitution, PjUri, Request, Uri}; - use payjoin_test_utils::corepc_node::vtype::ListUnspentItem; - use payjoin_test_utils::corepc_node::AddressType; - use payjoin_test_utils::{corepc_node, init_bitcoind_sender_receiver, init_tracing, BoxError}; - use serde_json::json; - - const EXAMPLE_URL: &str = "https://example.com"; - /// Transaction weight components for fee calculation - /// Useful resource: https://bitcoin.stackexchange.com/a/84006 - const TX_HEADER_LEGACY_WEIGHT: u64 = 40; - const TX_HEADER_WEIGHT: u64 = 42; - const P2PKH_INPUT_WEIGHT: u64 = 592; - const NESTED_P2WPKH_INPUT_WEIGHT: u64 = 364; - const P2WPKH_INPUT_WEIGHT: u64 = 272; - const P2TR_INPUT_WEIGHT: u64 = 230; - const P2WPKH_OUTPUT_WEIGHT: u64 = 124; - #[cfg(feature = "v1")] mod v1 { + use std::collections::HashMap; + use std::str::FromStr; + + use bitcoin::policy::DEFAULT_MIN_RELAY_TX_FEE; + use bitcoin::psbt::{Input as PsbtInput, Psbt}; + use bitcoin::{Amount, FeeRate, OutPoint, TxIn, TxOut, Weight}; + use payjoin::receive::v1::build_v1_pj_uri; + use payjoin::receive::InputPair; use payjoin::send::v1::SenderBuilder; - use payjoin::UriExt; + use payjoin::{ImplementationError, OutputSubstitution, PjUri, Request, Uri, UriExt}; + use payjoin_test_utils::corepc_node::vtype::ListUnspentItem; + use payjoin_test_utils::corepc_node::AddressType; + use payjoin_test_utils::{ + corepc_node, init_bitcoind_sender_receiver, init_tracing, BoxError, EXAMPLE_URL, + }; + use serde_json::json; use tracing::debug; - use super::*; + /// Transaction weight components for fee calculation + /// Useful resource: https://bitcoin.stackexchange.com/a/84006 + const TX_HEADER_LEGACY_WEIGHT: u64 = 40; + pub(super) const TX_HEADER_WEIGHT: u64 = 42; + const P2PKH_INPUT_WEIGHT: u64 = 592; + const NESTED_P2WPKH_INPUT_WEIGHT: u64 = 364; + pub(super) const P2WPKH_INPUT_WEIGHT: u64 = 272; + const P2TR_INPUT_WEIGHT: u64 = 230; + pub(super) const P2WPKH_OUTPUT_WEIGHT: u64 = 124; #[test] fn v1_to_v1_p2pkh() -> Result<(), BoxError> { @@ -186,28 +181,240 @@ mod integration { assert!(handle_v1_pj_request(req, headers, &receiver, None, None, None).is_ok()); Ok(()) } + + pub(super) fn build_original_psbt( + sender: &corepc_node::Client, + pj_uri: &PjUri, + ) -> Result { + let mut outputs = HashMap::with_capacity(1); + outputs.insert( + pj_uri.address.to_string(), + pj_uri.amount.unwrap_or(Amount::ONE_BTC).to_btc(), + ); + let options = json!({ + "lockUnspents": true, + // The minimum relay feerate ensures that tests fail if the receiver would add inputs/outputs + // that cannot be covered by the sender's additional fee contributions. + "feeRate": Amount::from_sat(DEFAULT_MIN_RELAY_TX_FEE.into()).to_btc(), + }); + let psbt = sender + // call RPC manually to pass custom options + .call::( + "walletcreatefundedpsbt", + &[ + json!(&[] as &[serde_json::Value]), // inputs + json!(&outputs), + json!(None as Option), // locktime + json!(options), + json!(Some(true)), // check that the sender properly clears keypaths + ], + )? + .psbt; + let psbt = sender.wallet_process_psbt(&Psbt::from_str(&psbt)?)?.psbt; + Ok(Psbt::from_str(&psbt)?) + } + + // Receiver receive and process original_psbt from a sender + // In production it it will come in as an HTTP request (over ssl or onion) + pub(super) fn handle_v1_pj_request( + req: Request, + headers: impl payjoin::receive::v1::Headers, + receiver: &corepc_node::Client, + custom_outputs: Option>, + drain_script: Option<&bitcoin::Script>, + custom_inputs: Option>, + ) -> Result { + // Receiver receive payjoin proposal, IRL it will be an HTTP request (over ssl or onion) + let proposal = payjoin::receive::v1::UncheckedOriginalPayload::from_request( + req.body.as_slice(), + url::Url::from_str(&req.url).expect("Could not parse url").query().unwrap_or(""), + headers, + )?; + let proposal = + handle_proposal(proposal, receiver, custom_outputs, drain_script, custom_inputs)?; + let psbt = proposal.psbt(); + tracing::debug!("Receiver's Payjoin proposal PSBT: {psbt:#?}"); + Ok(psbt.to_string()) + } + + pub(super) fn handle_proposal( + proposal: payjoin::receive::v1::UncheckedOriginalPayload, + receiver: &corepc_node::Client, + custom_outputs: Option>, + drain_script: Option<&bitcoin::Script>, + custom_inputs: Option>, + ) -> Result { + // Receive Check 1: Can Broadcast + let proposal = proposal.check_broadcast_suitability(None, |tx| { + Ok(receiver + .test_mempool_accept(std::slice::from_ref(tx)) + .map_err(ImplementationError::new)? + .0 + .first() + .ok_or(ImplementationError::from("testmempoolaccept should return a result"))? + .allowed) + })?; + // in a payment processor where the sender could go offline, this is where you schedule to broadcast the original_tx + let _to_broadcast_in_failure_case = proposal.extract_tx_to_schedule_broadcast(); + + // Receive Check 2: receiver can't sign for proposal inputs + let proposal = proposal.check_inputs_not_owned(&mut |input| { + let address = bitcoin::Address::from_script(input, bitcoin::Network::Regtest) + .map_err(ImplementationError::new)?; + receiver + .get_address_info(&address) + .map(|info| info.is_mine) + .map_err(ImplementationError::new) + })?; + + // Receive Check 3: have we seen this input before? More of a check for non-interactive i.e. payment processor receivers. + let payjoin = proposal + .check_no_inputs_seen_before(&mut |_| Ok(false))? + .identify_receiver_outputs(&mut |output_script| { + let address = + bitcoin::Address::from_script(output_script, bitcoin::Network::Regtest) + .map_err(ImplementationError::new)?; + receiver + .get_address_info(&address) + .map(|info| info.is_mine) + .map_err(ImplementationError::new) + })?; + + let payjoin = match custom_outputs { + Some(txos) => payjoin.replace_receiver_outputs( + txos, + drain_script.expect("drain_script should be provided with custom_outputs"), + )?, + None => + payjoin.substitute_receiver_script(&receiver.new_address()?.script_pubkey())?, + } + .commit_outputs(); + + let inputs = match custom_inputs { + Some(inputs) => inputs, + None => { + let candidate_inputs = + receiver.list_unspent()?.0.into_iter().map(input_pair_from_list_unspent); + let selected_input = + payjoin.try_preserving_privacy(candidate_inputs).map_err(|e| { + format!("Failed to make privacy preserving selection: {e:?}") + })?; + vec![selected_input] + } + }; + let payjoin = payjoin + .contribute_inputs(inputs) + .map_err(|e| format!("Failed to contribute inputs: {e:?}"))? + .commit_inputs(); + let payjoin = payjoin.apply_fee_range( + Some(FeeRate::BROADCAST_MIN), + Some(FeeRate::from_sat_per_vb_unchecked(2)), + )?; + + let payjoin_proposal = payjoin.finalize_proposal(|psbt: &Psbt| { + receiver + // call RPC manually to pass custom options + .call::( + "walletprocesspsbt", + &[ + json!(psbt.to_string()), + json!(None as Option), + json!(None as Option<&str>), + json!(Some(true)), // check that the receiver properly clears keypaths + ], + ) + .map(|res| Psbt::from_str(&res.psbt).expect("psbt should be valid")) + .map_err(ImplementationError::new) + })?; + Ok(payjoin_proposal) + } + + pub(super) fn extract_pj_tx( + sender: &corepc_node::Client, + psbt: Psbt, + ) -> Result> { + let payjoin_psbt = sender.wallet_process_psbt(&psbt)?.psbt; + let payjoin_psbt = sender + .finalize_psbt(&Psbt::from_str(&payjoin_psbt)?)? + .psbt + .expect("should contain a PSBT"); + let payjoin_psbt = Psbt::from_str(&payjoin_psbt)?; + tracing::debug!("Sender's Payjoin PSBT: {payjoin_psbt:#?}"); + + Ok(payjoin_psbt.extract_tx()?) + } + + pub(super) fn input_pair_from_list_unspent(utxo: ListUnspentItem) -> InputPair { + let utxo = + utxo.into_model().expect("listunspent utxo should be convertible to model type"); + let psbtin = PsbtInput { + // NOTE: non_witness_utxo is not necessary because bitcoin-cli always supplies + // witness_utxo, even for non-witness inputs + witness_utxo: Some(TxOut { + value: utxo.amount.to_unsigned().expect("amount should be unsigned"), + script_pubkey: utxo.script_pubkey, + }), + redeem_script: utxo.redeem_script, + //FIXME needs later corepc_node bitcoin version + //witness_script: utxo.witness_script.clone(), + ..Default::default() + }; + let txin = TxIn { + previous_output: OutPoint { txid: utxo.txid, vout: utxo.vout }, + ..Default::default() + }; + InputPair::new(txin, psbtin, None).expect("Input pair should be valid") + } + + pub(super) struct HeaderMock(HashMap); + + impl payjoin::receive::v1::Headers for HeaderMock { + fn get_header(&self, key: &str) -> Option<&str> { self.0.get(key).map(|e| e.as_str()) } + } + + impl HeaderMock { + pub(super) fn new(body: &[u8], content_type: &str) -> HeaderMock { + let mut h = HashMap::new(); + h.insert("content-type".to_string(), content_type.to_string()); + h.insert("content-length".to_string(), body.len().to_string()); + HeaderMock(h) + } + } } // not all needs v1 #[cfg(all(feature = "io", feature = "v2", feature = "v1", feature = "_manual-tls"))] mod v2 { + use std::collections::HashMap; + use std::str::FromStr; use std::sync::Arc; use std::time::Duration; - use bitcoin::Address; + use bitcoin::bech32::primitives::decode::CheckedHrpstring; + use bitcoin::bech32::NoChecksum; + use bitcoin::policy::DEFAULT_MIN_RELAY_TX_FEE; + use bitcoin::{Address, Amount, FeeRate, Psbt, Weight}; use http::StatusCode; use payjoin::persist::{NoopSessionPersister, OptionalTransitionOutcome}; + use payjoin::receive::v1::build_v1_pj_uri; use payjoin::receive::v2::{ replay_event_log as replay_receiver_event_log, PayjoinProposal, ReceiveSession, Receiver, ReceiverBuilder, SessionStatus, UncheckedOriginalPayload, }; + use payjoin::receive::InputPair; use payjoin::send::v2::SenderBuilder; use payjoin::send::ResponseError; - use payjoin::{OhttpKeys, PjUri, UriExt}; - use payjoin_test_utils::{BoxSendSyncError, InMemoryTestPersister, TestServices}; + use payjoin::{ + ImplementationError, OhttpKeys, OutputSubstitution, PjUri, Request, Uri, UriExt, + }; + use payjoin_test_utils::{ + corepc_node, init_bitcoind_sender_receiver, init_tracing, BoxError, BoxSendSyncError, + InMemoryTestPersister, TestServices, EXAMPLE_URL, + }; use reqwest::{Client, Response}; + use serde_json::json; - use super::*; + use super::v1::*; #[tokio::test] async fn test_bad_ohttp_keys() -> Result<(), BoxSendSyncError> { @@ -997,10 +1204,18 @@ mod integration { #[cfg(feature = "v1")] mod batching { + use std::str::FromStr; + + use bitcoin::{Amount, FeeRate, TxOut, Weight}; + use payjoin::receive::v1::build_v1_pj_uri; use payjoin::send::v1::SenderBuilder; - use payjoin::UriExt; + use payjoin::{OutputSubstitution, Uri, UriExt}; + use payjoin_test_utils::corepc_node::AddressType; + use payjoin_test_utils::{ + init_bitcoind_sender_receiver, init_tracing, BoxError, EXAMPLE_URL, + }; - use super::*; + use super::v1::*; // In this test the receiver consolidates a bunch of UTXOs into the destination output #[test] @@ -1182,195 +1397,4 @@ mod integration { Ok(()) } } - - fn build_original_psbt(sender: &corepc_node::Client, pj_uri: &PjUri) -> Result { - let mut outputs = HashMap::with_capacity(1); - outputs - .insert(pj_uri.address.to_string(), pj_uri.amount.unwrap_or(Amount::ONE_BTC).to_btc()); - let options = json!({ - "lockUnspents": true, - // The minimum relay feerate ensures that tests fail if the receiver would add inputs/outputs - // that cannot be covered by the sender's additional fee contributions. - "feeRate": Amount::from_sat(DEFAULT_MIN_RELAY_TX_FEE.into()).to_btc(), - }); - let psbt = sender - // call RPC manually to pass custom options - .call::( - "walletcreatefundedpsbt", - &[ - json!(&[] as &[serde_json::Value]), // inputs - json!(&outputs), - json!(None as Option), // locktime - json!(options), - json!(Some(true)), // check that the sender properly clears keypaths - ], - )? - .psbt; - let psbt = sender.wallet_process_psbt(&Psbt::from_str(&psbt)?)?.psbt; - Ok(Psbt::from_str(&psbt)?) - } - - // Receiver receive and process original_psbt from a sender - // In production it it will come in as an HTTP request (over ssl or onion) - fn handle_v1_pj_request( - req: Request, - headers: impl payjoin::receive::v1::Headers, - receiver: &corepc_node::Client, - custom_outputs: Option>, - drain_script: Option<&bitcoin::Script>, - custom_inputs: Option>, - ) -> Result { - // Receiver receive payjoin proposal, IRL it will be an HTTP request (over ssl or onion) - let proposal = payjoin::receive::v1::UncheckedOriginalPayload::from_request( - req.body.as_slice(), - url::Url::from_str(&req.url).expect("Could not parse url").query().unwrap_or(""), - headers, - )?; - let proposal = - handle_proposal(proposal, receiver, custom_outputs, drain_script, custom_inputs)?; - let psbt = proposal.psbt(); - tracing::debug!("Receiver's Payjoin proposal PSBT: {psbt:#?}"); - Ok(psbt.to_string()) - } - - fn handle_proposal( - proposal: payjoin::receive::v1::UncheckedOriginalPayload, - receiver: &corepc_node::Client, - custom_outputs: Option>, - drain_script: Option<&bitcoin::Script>, - custom_inputs: Option>, - ) -> Result { - // Receive Check 1: Can Broadcast - let proposal = proposal.check_broadcast_suitability(None, |tx| { - Ok(receiver - .test_mempool_accept(std::slice::from_ref(tx)) - .map_err(ImplementationError::new)? - .0 - .first() - .ok_or(ImplementationError::from("testmempoolaccept should return a result"))? - .allowed) - })?; - // in a payment processor where the sender could go offline, this is where you schedule to broadcast the original_tx - let _to_broadcast_in_failure_case = proposal.extract_tx_to_schedule_broadcast(); - - // Receive Check 2: receiver can't sign for proposal inputs - let proposal = proposal.check_inputs_not_owned(&mut |input| { - let address = bitcoin::Address::from_script(input, bitcoin::Network::Regtest) - .map_err(ImplementationError::new)?; - receiver - .get_address_info(&address) - .map(|info| info.is_mine) - .map_err(ImplementationError::new) - })?; - - // Receive Check 3: have we seen this input before? More of a check for non-interactive i.e. payment processor receivers. - let payjoin = proposal - .check_no_inputs_seen_before(&mut |_| Ok(false))? - .identify_receiver_outputs(&mut |output_script| { - let address = - bitcoin::Address::from_script(output_script, bitcoin::Network::Regtest) - .map_err(ImplementationError::new)?; - receiver - .get_address_info(&address) - .map(|info| info.is_mine) - .map_err(ImplementationError::new) - })?; - - let payjoin = match custom_outputs { - Some(txos) => payjoin.replace_receiver_outputs( - txos, - drain_script.expect("drain_script should be provided with custom_outputs"), - )?, - None => payjoin.substitute_receiver_script(&receiver.new_address()?.script_pubkey())?, - } - .commit_outputs(); - - let inputs = match custom_inputs { - Some(inputs) => inputs, - None => { - let candidate_inputs = - receiver.list_unspent()?.0.into_iter().map(input_pair_from_list_unspent); - let selected_input = payjoin - .try_preserving_privacy(candidate_inputs) - .map_err(|e| format!("Failed to make privacy preserving selection: {e:?}"))?; - vec![selected_input] - } - }; - let payjoin = payjoin - .contribute_inputs(inputs) - .map_err(|e| format!("Failed to contribute inputs: {e:?}"))? - .commit_inputs(); - let payjoin = payjoin.apply_fee_range( - Some(FeeRate::BROADCAST_MIN), - Some(FeeRate::from_sat_per_vb_unchecked(2)), - )?; - - let payjoin_proposal = payjoin.finalize_proposal(|psbt: &Psbt| { - receiver - // call RPC manually to pass custom options - .call::( - "walletprocesspsbt", - &[ - json!(psbt.to_string()), - json!(None as Option), - json!(None as Option<&str>), - json!(Some(true)), // check that the receiver properly clears keypaths - ], - ) - .map(|res| Psbt::from_str(&res.psbt).expect("psbt should be valid")) - .map_err(ImplementationError::new) - })?; - Ok(payjoin_proposal) - } - - fn extract_pj_tx( - sender: &corepc_node::Client, - psbt: Psbt, - ) -> Result> { - let payjoin_psbt = sender.wallet_process_psbt(&psbt)?.psbt; - let payjoin_psbt = sender - .finalize_psbt(&Psbt::from_str(&payjoin_psbt)?)? - .psbt - .expect("should contain a PSBT"); - let payjoin_psbt = Psbt::from_str(&payjoin_psbt)?; - tracing::debug!("Sender's Payjoin PSBT: {payjoin_psbt:#?}"); - - Ok(payjoin_psbt.extract_tx()?) - } - - fn input_pair_from_list_unspent(utxo: ListUnspentItem) -> InputPair { - let utxo = utxo.into_model().expect("listunspent utxo should be convertible to model type"); - let psbtin = PsbtInput { - // NOTE: non_witness_utxo is not necessary because bitcoin-cli always supplies - // witness_utxo, even for non-witness inputs - witness_utxo: Some(TxOut { - value: utxo.amount.to_unsigned().expect("amount should be unsigned"), - script_pubkey: utxo.script_pubkey, - }), - redeem_script: utxo.redeem_script, - //FIXME needs later corepc_node bitcoin version - //witness_script: utxo.witness_script.clone(), - ..Default::default() - }; - let txin = TxIn { - previous_output: OutPoint { txid: utxo.txid, vout: utxo.vout }, - ..Default::default() - }; - InputPair::new(txin, psbtin, None).expect("Input pair should be valid") - } - - struct HeaderMock(HashMap); - - impl payjoin::receive::v1::Headers for HeaderMock { - fn get_header(&self, key: &str) -> Option<&str> { self.0.get(key).map(|e| e.as_str()) } - } - - impl HeaderMock { - fn new(body: &[u8], content_type: &str) -> HeaderMock { - let mut h = HashMap::new(); - h.insert("content-type".to_string(), content_type.to_string()); - h.insert("content-length".to_string(), body.len().to_string()); - HeaderMock(h) - } - } } From 65f2cfd21f4ea6d5d7401dfaeb9fca22a4c00e18 Mon Sep 17 00:00:00 2001 From: user Date: Wed, 3 Sep 2025 15:27:58 -0400 Subject: [PATCH 3/3] Add test suite for v1 and v2 specific features alone Add the test suite for the `contrib/test,sh` script in the payjoin crate. --- payjoin/contrib/test.sh | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/payjoin/contrib/test.sh b/payjoin/contrib/test.sh index d0e949c33..0b9600feb 100755 --- a/payjoin/contrib/test.sh +++ b/payjoin/contrib/test.sh @@ -1,5 +1,12 @@ #!/usr/bin/env bash set -e +features=("v1" "v2") + cargo test --locked --package payjoin --verbose --all-features --lib cargo test --locked --package payjoin --verbose --all-features --test integration + +for feature in "${features[@]}"; do + cargo test --locked --package payjoin --verbose --no-default-features --features "$feature" --lib + cargo test --locked --package payjoin --verbose --no-default-features --features "$feature" --test integration +done