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 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..999553582 100644 --- a/payjoin/src/core/uri/v1.rs +++ b/payjoin/src/core/uri/v1.rs @@ -31,3 +31,180 @@ 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::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( 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) - } - } }