From daee12d3df54f7b0c36e87243cb05064491b38f9 Mon Sep 17 00:00:00 2001 From: DanGould Date: Mon, 6 Jan 2025 17:38:04 -0500 Subject: [PATCH 1/5] Replace PjUriBuilder with mutable PjUri The builder pattern was feature gated and confusing when there were really only two distinct ways to build Uris: 1. From plain parameters in v1, from which to add amount, label, etc. 2. From a v2 receiver, from which to add amount, label, etc. amount, label, message, etc. can be set by mutating the bitcoin_uri::Uri struct directly, and we weren't enforcing anything special like pjos in the builder that we could not in the v2::Receiver directly. So we can delete code and have a simple interface that gets us closer to additive v1/v2 features. --- payjoin-cli/src/app/v1.rs | 7 +- payjoin-cli/src/app/v2.rs | 7 +- payjoin/src/lib.rs | 2 +- payjoin/src/receive/mod.rs | 12 ++- payjoin/src/receive/v2/mod.rs | 54 +++++++++---- payjoin/src/uri/mod.rs | 139 ---------------------------------- payjoin/tests/integration.rs | 55 +++++--------- 7 files changed, 79 insertions(+), 197 deletions(-) diff --git a/payjoin-cli/src/app/v1.rs b/payjoin-cli/src/app/v1.rs index 5636ff38..d6cb1747 100644 --- a/payjoin-cli/src/app/v1.rs +++ b/payjoin-cli/src/app/v1.rs @@ -17,7 +17,7 @@ use payjoin::bitcoin::psbt::Psbt; use payjoin::bitcoin::{self, FeeRate}; use payjoin::receive::{PayjoinProposal, UncheckedProposal}; use payjoin::send::v1::SenderBuilder; -use payjoin::{Error, PjUriBuilder, Uri, UriExt}; +use payjoin::{Error, Uri, UriExt}; use tokio::net::TcpListener; use super::config::AppConfig; @@ -137,7 +137,8 @@ impl App { let pj_part = payjoin::Url::parse(pj_part) .map_err(|e| anyhow!("Failed to parse pj_endpoint: {}", e))?; - let pj_uri = PjUriBuilder::new(pj_receiver_address, pj_part).amount(amount).build(); + let mut pj_uri = payjoin::receive::build_v1_pj_uri(&pj_receiver_address, &pj_part, false); + pj_uri.amount = Some(amount); Ok(pj_uri.to_string()) } @@ -263,7 +264,7 @@ impl App { } else { format!("{}?pj={}", address.to_qr_uri(), self.config.pj_endpoint) }; - let uri = payjoin::Uri::try_from(uri_string.clone()) + let uri = Uri::try_from(uri_string.clone()) .map_err(|_| Error::Server(anyhow!("Could not parse payjoin URI string.").into()))?; let _ = uri.assume_checked(); // we just got it from bitcoind above diff --git a/payjoin-cli/src/app/v2.rs b/payjoin-cli/src/app/v2.rs index 8e004952..66bf9ca2 100644 --- a/payjoin-cli/src/app/v2.rs +++ b/payjoin-cli/src/app/v2.rs @@ -117,11 +117,8 @@ impl App { amount: Option, ) -> Result<()> { println!("Receive session established"); - let mut pj_uri_builder = session.pj_uri_builder(); - if let Some(amount) = amount { - pj_uri_builder = pj_uri_builder.amount(amount); - } - let pj_uri = pj_uri_builder.build(); + let mut pj_uri = session.pj_uri(); + pj_uri.amount = amount; println!("Request Payjoin by sharing this Payjoin Uri:"); println!("{}", pj_uri); diff --git a/payjoin/src/lib.rs b/payjoin/src/lib.rs index 5d77ce58..a985c21d 100644 --- a/payjoin/src/lib.rs +++ b/payjoin/src/lib.rs @@ -52,5 +52,5 @@ mod uri; #[cfg(feature = "base64")] pub use bitcoin::base64; -pub use uri::{PjParseError, PjUri, PjUriBuilder, Uri, UriExt}; +pub use uri::{PjParseError, PjUri, Uri, UriExt}; pub use url::{ParseError, Url}; diff --git a/payjoin/src/receive/mod.rs b/payjoin/src/receive/mod.rs index 22b17e4a..4f615958 100644 --- a/payjoin/src/receive/mod.rs +++ b/payjoin/src/receive/mod.rs @@ -4,7 +4,7 @@ //! Usage is pretty simple: //! //! 1. Generate a pj_uri [BIP 21](https://github.com/bitcoin/bips/blob/master/bip-0021.mediawiki) -//! using [`payjoin::Uri`](crate::Uri)::from_str +//! using [`build_v1_pj_uri`] //! 2. Listen for a sender's request on the `pj` endpoint //! 3. Parse the request using //! [`UncheckedProposal::from_request()`](crate::receive::UncheckedProposal::from_request()) @@ -92,6 +92,16 @@ impl<'a> From<&'a InputPair> for InternalInputPair<'a> { fn from(pair: &'a InputPair) -> Self { Self { psbtin: &pair.psbtin, txin: &pair.txin } } } +pub fn build_v1_pj_uri<'a>( + address: &bitcoin::Address, + endpoint: &url::Url, + disable_output_substitution: bool, +) -> crate::uri::PjUri<'a> { + let extras = + crate::uri::PayjoinExtras { endpoint: endpoint.clone(), disable_output_substitution }; + bitcoin_uri::Uri::with_extras(address.clone(), extras) +} + /// The sender's original PSBT and optional parameters /// /// This type is used to process the request. It is returned by diff --git a/payjoin/src/receive/v2/mod.rs b/payjoin/src/receive/v2/mod.rs index 2881636d..c3d2a9c7 100644 --- a/payjoin/src/receive/v2/mod.rs +++ b/payjoin/src/receive/v2/mod.rs @@ -19,7 +19,7 @@ use crate::psbt::PsbtExt; use crate::receive::optional_parameters::Params; use crate::receive::InputPair; use crate::uri::ShortId; -use crate::{PjUriBuilder, Request}; +use crate::Request; pub(crate) mod error; @@ -140,7 +140,7 @@ impl Receiver { ([u8; crate::ohttp::ENCAPSULATED_MESSAGE_BYTES], ohttp::ClientResponse), OhttpEncapsulationError, > { - let fallback_target = self.pj_url(); + let fallback_target = self.subdir(); ohttp_encapsulate(&mut self.context.ohttp_keys, "GET", fallback_target.as_str(), None) } @@ -185,19 +185,20 @@ impl Receiver { Ok(UncheckedProposal { inner, context: self.context.clone() }) } - pub fn pj_uri_builder(&self) -> PjUriBuilder { - PjUriBuilder::new( - self.context.address.clone(), - self.pj_url(), - Some(self.context.s.public_key().clone()), - Some(self.context.ohttp_keys.clone()), - Some(self.context.expiry), - ) + /// Build a V2 Payjoin URI from the receiver's context + pub fn pj_uri<'a>(&self) -> crate::PjUri<'a> { + use crate::uri::{PayjoinExtras, UrlExt}; + let mut pj = self.subdir().clone(); + pj.set_receiver_pubkey(self.context.s.public_key().clone()); + pj.set_ohttp(self.context.ohttp_keys.clone()); + pj.set_exp(self.context.expiry); + let extras = PayjoinExtras { endpoint: pj, disable_output_substitution: false }; + bitcoin_uri::Uri::with_extras(self.context.address.clone(), extras) } - // The contents of the `&pj=` query parameter. - // This identifies a session at the payjoin directory server. - pub fn pj_url(&self) -> Url { + /// The subdirectory for this Payjoin receiver session. + /// It consists of a directory URL and the session ShortID in the path. + pub fn subdir(&self) -> Url { let mut url = self.context.directory.clone(); { let mut path_segments = @@ -568,4 +569,31 @@ mod test { let deserialized: Receiver = serde_json::from_str(&serialized).unwrap(); assert_eq!(session, deserialized); } + + #[test] + fn test_v2_pj_uri() { + let address = bitcoin::Address::from_str("12c6DSiU4Rq3P4ZxziKxzrL5LmMBrzjrJX") + .unwrap() + .assume_checked(); + let receiver_keys = crate::hpke::HpkeKeyPair::gen_keypair(); + let ohttp_keys = + OhttpKeys::from_str("OH1QYPM5JXYNS754Y4R45QWE336QFX6ZR8DQGVQCULVZTV20TFVEYDMFQC") + .expect("Invalid OhttpKeys"); + let arbitrary_url = Url::parse("https://example.com").unwrap(); + let uri = Receiver { + context: SessionContext { + address, + directory: arbitrary_url.clone(), + subdirectory: None, + ohttp_keys, + ohttp_relay: arbitrary_url.clone(), + expiry: SystemTime::now() + Duration::from_secs(60), + s: receiver_keys, + e: None, + }, + } + .pj_uri(); + assert_ne!(uri.extras.endpoint, arbitrary_url); + assert!(!uri.extras.disable_output_substitution); + } } diff --git a/payjoin/src/uri/mod.rs b/payjoin/src/uri/mod.rs index 25e169c8..e6aacece 100644 --- a/payjoin/src/uri/mod.rs +++ b/payjoin/src/uri/mod.rs @@ -1,17 +1,12 @@ use std::borrow::Cow; use bitcoin::address::NetworkChecked; -use bitcoin::{Address, Amount}; pub use error::PjParseError; use url::Url; -#[cfg(feature = "v2")] -use crate::hpke::HpkePublicKey; use crate::uri::error::InternalPjParseError; #[cfg(feature = "v2")] pub(crate) use crate::uri::url_ext::UrlExt; -#[cfg(feature = "v2")] -use crate::OhttpKeys; pub mod error; #[cfg(feature = "v2")] @@ -141,97 +136,6 @@ impl<'a> UriExt<'a> for Uri<'a, NetworkChecked> { } } -/// Build a valid `PjUri`. -/// -/// Payjoin receiver can use this builder to create a payjoin -/// uri to send to the sender. -#[derive(Clone)] -pub struct PjUriBuilder { - /// Address you want to receive funds to. - address: Address, - /// Amount you want to receive. - /// - /// If `None` the amount will be left unspecified. - amount: Option, - /// Message - message: Option, - /// Label - label: Option, - /// Payjoin endpoint url listening for payjoin requests. - pj: Url, - /// Whether or not payjoin output substitution is allowed - pjos: bool, -} - -impl PjUriBuilder { - /// Create a new `PjUriBuilder` with required parameters. - /// - /// ## Parameters - /// - `address`: Represents a bitcoin address. - /// - `origin`: Represents either the payjoin endpoint in v1 or the directory in v2. - /// - `ohttp_keys`: Optional OHTTP keys for v2 (only available if the "v2" feature is enabled). - /// - `expiry`: Optional non-default expiry for the payjoin session (only available if the "v2" feature is enabled). - pub fn new( - address: Address, - origin: Url, - #[cfg(feature = "v2")] receiver_pubkey: Option, - #[cfg(feature = "v2")] ohttp_keys: Option, - #[cfg(feature = "v2")] expiry: Option, - ) -> Self { - #[allow(unused_mut)] - let mut pj = origin; - #[cfg(feature = "v2")] - if let Some(receiver_pubkey) = receiver_pubkey { - pj.set_receiver_pubkey(receiver_pubkey); - } - #[cfg(feature = "v2")] - if let Some(ohttp_keys) = ohttp_keys { - pj.set_ohttp(ohttp_keys); - } - #[cfg(feature = "v2")] - if let Some(expiry) = expiry { - pj.set_exp(expiry); - } - Self { address, amount: None, message: None, label: None, pj, pjos: false } - } - /// Set the amount you want to receive. - pub fn amount(mut self, amount: Amount) -> Self { - self.amount = Some(amount); - self - } - - /// Set the message. - pub fn message(mut self, message: String) -> Self { - self.message = Some(message); - self - } - - /// Set the label. - pub fn label(mut self, label: String) -> Self { - self.label = Some(label); - self - } - - /// Set whether or not payjoin output substitution is allowed. - pub fn pjos(mut self, pjos: bool) -> Self { - self.pjos = pjos; - self - } - - /// Build payjoin URI. - /// - /// Constructs a `bitcoin_uri::Uri` with PayjoinParams from the - /// parameters set in the builder. - pub fn build<'a>(self) -> PjUri<'a> { - let extras = PayjoinExtras { endpoint: self.pj, disable_output_substitution: self.pjos }; - let mut pj_uri = bitcoin_uri::Uri::with_extras(self.address, extras); - pj_uri.amount = self.amount; - pj_uri.label = self.label.map(Into::into); - pj_uri.message = self.message.map(Into::into); - pj_uri - } -} - impl PayjoinExtras { pub fn is_output_substitution_disabled(&self) -> bool { self.disable_output_substitution } } @@ -417,47 +321,4 @@ mod tests { .extras .pj_is_supported()); } - - #[test] - fn test_builder() { - use std::str::FromStr; - - use url::Url; - use PjUriBuilder; - let https = "https://example.com/"; - let onion = "http://vjdpwgybvubne5hda6v4c5iaeeevhge6jvo3w2cl6eocbwwvwxp7b7qd.onion/"; - let base58 = "12c6DSiU4Rq3P4ZxziKxzrL5LmMBrzjrJX"; - let bech32_upper = "TB1Q6D3A2W975YNY0ASUVD9A67NER4NKS58FF0Q8G4"; - let bech32_lower = "tb1q6d3a2w975yny0asuvd9a67ner4nks58ff0q8g4"; - - for address in [base58, bech32_upper, bech32_lower] { - for pj in [https, onion] { - let address = bitcoin::Address::from_str(address).unwrap().assume_checked(); - let amount = bitcoin::Amount::ONE_BTC; - let builder = PjUriBuilder::new( - address.clone(), - Url::parse(pj).unwrap(), - #[cfg(feature = "v2")] - None, - #[cfg(feature = "v2")] - None, - #[cfg(feature = "v2")] - None, - ) - .amount(amount) - .message("message".to_string()) - .label("label".to_string()) - .pjos(true); - let uri = builder.build(); - assert_eq!(uri.address, address); - assert_eq!(uri.amount.unwrap(), bitcoin::Amount::ONE_BTC); - let label: Cow<'_, str> = uri.label.clone().unwrap().try_into().unwrap(); - let message: Cow<'_, str> = uri.message.clone().unwrap().try_into().unwrap(); - assert_eq!(label, "label"); - assert_eq!(message, "message"); - assert!(uri.extras.disable_output_substitution); - assert_eq!(uri.extras.endpoint.to_string(), pj.to_string()); - } - } - } } diff --git a/payjoin/tests/integration.rs b/payjoin/tests/integration.rs index 39f8a704..419f5160 100644 --- a/payjoin/tests/integration.rs +++ b/payjoin/tests/integration.rs @@ -12,8 +12,8 @@ mod integration { use bitcoind::bitcoincore_rpc::{self, RpcApi}; use log::{log_enabled, Level}; use once_cell::sync::{Lazy, OnceCell}; - use payjoin::receive::InputPair; - use payjoin::{PjUri, PjUriBuilder, Request, Uri}; + use payjoin::receive::{build_v1_pj_uri, InputPair}; + use payjoin::{PjUri, Request, Uri}; use tracing_subscriber::{EnvFilter, FmtSubscriber}; use url::Url; @@ -79,9 +79,8 @@ mod integration { ) -> Result<(), BoxError> { // Receiver creates the payjoin URI let pj_receiver_address = receiver.get_new_address(None, None)?.assume_checked(); - let pj_uri = PjUriBuilder::new(pj_receiver_address, EXAMPLE_URL.to_owned()) - .amount(Amount::ONE_BTC) - .build(); + let mut pj_uri = build_v1_pj_uri(&pj_receiver_address, &EXAMPLE_URL, false); + pj_uri.amount = Some(Amount::ONE_BTC); // ********************** // Inside the Sender: @@ -144,9 +143,8 @@ mod integration { // Receiver creates the payjoin URI let pj_receiver_address = receiver.get_new_address(None, None)?.assume_checked(); - let pj_uri = PjUriBuilder::new(pj_receiver_address, EXAMPLE_URL.to_owned()) - .amount(Amount::ONE_BTC) - .build(); + let mut pj_uri = build_v1_pj_uri(&pj_receiver_address, &EXAMPLE_URL, false); + pj_uri.amount = Some(Amount::ONE_BTC); // ********************** // Inside the Sender: @@ -180,7 +178,7 @@ mod integration { use http::StatusCode; use payjoin::receive::v2::{PayjoinProposal, Receiver, UncheckedProposal}; use payjoin::send::v2::SenderBuilder; - use payjoin::{HpkeKeyPair, OhttpKeys, PjUri, UriExt}; + use payjoin::{OhttpKeys, PjUri, UriExt}; use reqwest::{Client, ClientBuilder, Error, Response}; use testcontainers_modules::redis::Redis; use testcontainers_modules::testcontainers::clients::Cli; @@ -281,32 +279,23 @@ mod integration { // Inside the Receiver: let address = receiver.get_new_address(None, None)?.assume_checked(); // test session with expiry in the past - let mut session = initialize_session( + let mut expired_receiver = initialize_session( address.clone(), directory.clone(), ohttp_keys.clone(), Some(Duration::from_secs(0)), ); - match session.extract_req() { + match expired_receiver.extract_req() { // Internal error types are private, so check against a string Err(err) => assert!(err.to_string().contains("expired")), _ => panic!("Expired receive session should error"), }; - let pj_uri = session.pj_uri_builder().build(); // ********************** // Inside the Sender: - let psbt = build_original_psbt(&sender, &pj_uri)?; + let psbt = build_original_psbt(&sender, &expired_receiver.pj_uri())?; // Test that an expired pj_url errors - let expired_pj_uri = payjoin::PjUriBuilder::new( - address, - directory.clone(), - Some(HpkeKeyPair::gen_keypair().public_key().clone()), - Some(ohttp_keys), - Some(std::time::SystemTime::now()), - ) - .build(); - let expired_req_ctx = SenderBuilder::new(psbt, expired_pj_uri) + let expired_req_ctx = SenderBuilder::new(psbt, expired_receiver.pj_uri()) .build_non_incentivizing(FeeRate::BROADCAST_MIN)?; match expired_req_ctx.extract_v2(directory.to_owned()) { // Internal error types are private, so check against a string @@ -369,7 +358,7 @@ mod integration { None, ); println!("session: {:#?}", &session); - let pj_uri_string = session.pj_uri_builder().build().to_string(); + let pj_uri_string = session.pj_uri().to_string(); // Poll receive request let (req, ctx) = session.extract_req()?; let response = agent.post(req.url).body(req.body).send().await?; @@ -540,7 +529,7 @@ mod integration { None, ); println!("session: {:#?}", &session); - let pj_uri_string = session.pj_uri_builder().build().to_string(); + let pj_uri_string = session.pj_uri().to_string(); // Poll receive request let (req, ctx) = session.extract_req()?; let response = agent.post(req.url).body(req.body).send().await?; @@ -638,10 +627,8 @@ mod integration { let (_bitcoind, sender, receiver) = init_bitcoind_sender_receiver(None, None)?; // Receiver creates the payjoin URI let pj_receiver_address = receiver.get_new_address(None, None)?.assume_checked(); - let pj_uri = - PjUriBuilder::new(pj_receiver_address, EXAMPLE_URL.to_owned(), None, None, None) - .amount(Amount::ONE_BTC) - .build(); + let mut pj_uri = build_v1_pj_uri(&pj_receiver_address, &EXAMPLE_URL, false); + pj_uri.amount = Some(Amount::ONE_BTC); // ********************** // Inside the Sender: @@ -725,7 +712,7 @@ mod integration { let mut session = initialize_session(address, directory, ohttp_keys.clone(), None); - let pj_uri_string = session.pj_uri_builder().build().to_string(); + let pj_uri_string = session.pj_uri().to_string(); // ********************** // Inside the V1 Sender: @@ -1034,9 +1021,8 @@ mod integration { // Receiver creates the payjoin URI let pj_receiver_address = receiver.get_new_address(None, None)?.assume_checked(); - let pj_uri = PjUriBuilder::new(pj_receiver_address, EXAMPLE_URL.to_owned()) - .amount(Amount::ONE_BTC) - .build(); + let mut pj_uri = build_v1_pj_uri(&pj_receiver_address, &EXAMPLE_URL, false); + pj_uri.amount = Some(Amount::ONE_BTC); // ********************** // Inside the Sender: @@ -1112,9 +1098,8 @@ mod integration { // Receiver creates the payjoin URI let pj_receiver_address = receiver.get_new_address(None, None)?.assume_checked(); - let pj_uri = PjUriBuilder::new(pj_receiver_address, EXAMPLE_URL.to_owned()) - .amount(Amount::ONE_BTC) - .build(); + let mut pj_uri = build_v1_pj_uri(&pj_receiver_address, &EXAMPLE_URL, false); + pj_uri.amount = Some(Amount::ONE_BTC); // ********************** // Inside the Sender: From a20764f30393515c927fa4b2164bb1b9d6dc5ed7 Mon Sep 17 00:00:00 2001 From: DanGould Date: Mon, 6 Jan 2025 23:19:07 -0500 Subject: [PATCH 2/5] Separate receive::{v1,v2} modules These are distinct and need to be able to be switched for features to be additive. --- payjoin-cli/src/app/v1.rs | 14 +- payjoin/src/receive/mod.rs | 1076 +-------------------------------- payjoin/src/receive/v1.rs | 1068 ++++++++++++++++++++++++++++++++ payjoin/src/receive/v2/mod.rs | 80 +-- payjoin/tests/integration.rs | 13 +- 5 files changed, 1129 insertions(+), 1122 deletions(-) create mode 100644 payjoin/src/receive/v1.rs diff --git a/payjoin-cli/src/app/v1.rs b/payjoin-cli/src/app/v1.rs index d6cb1747..d8109934 100644 --- a/payjoin-cli/src/app/v1.rs +++ b/payjoin-cli/src/app/v1.rs @@ -15,7 +15,7 @@ use hyper::{Method, Request, Response, StatusCode}; use hyper_util::rt::TokioIo; use payjoin::bitcoin::psbt::Psbt; use payjoin::bitcoin::{self, FeeRate}; -use payjoin::receive::{PayjoinProposal, UncheckedProposal}; +use payjoin::receive::v1::{PayjoinProposal, UncheckedProposal}; use payjoin::send::v1::SenderBuilder; use payjoin::{Error, Uri, UriExt}; use tokio::net::TcpListener; @@ -28,7 +28,7 @@ use crate::db::Database; pub const LOCAL_CERT_FILE: &str = "localhost.der"; struct Headers<'a>(&'a hyper::HeaderMap); -impl payjoin::receive::Headers for Headers<'_> { +impl payjoin::receive::v1::Headers for Headers<'_> { fn get_header(&self, key: &str) -> Option<&str> { self.0.get(key).map(|v| v.to_str()).transpose().ok().flatten() } @@ -137,7 +137,8 @@ impl App { let pj_part = payjoin::Url::parse(pj_part) .map_err(|e| anyhow!("Failed to parse pj_endpoint: {}", e))?; - let mut pj_uri = payjoin::receive::build_v1_pj_uri(&pj_receiver_address, &pj_part, false); + let mut pj_uri = + payjoin::receive::v1::build_v1_pj_uri(&pj_receiver_address, &pj_part, false); pj_uri.amount = Some(amount); Ok(pj_uri.to_string()) @@ -279,8 +280,7 @@ impl App { let headers = Headers(&parts.headers); let query_string = parts.uri.query().unwrap_or(""); let body = body.collect().await.map_err(|e| Error::Server(e.into()))?.aggregate().reader(); - let proposal = - payjoin::receive::UncheckedProposal::from_request(body, query_string, headers)?; + let proposal = UncheckedProposal::from_request(body, query_string, headers)?; let payjoin_proposal = self.process_v1_proposal(proposal)?; let psbt = payjoin_proposal.psbt(); @@ -380,9 +380,9 @@ impl App { } fn try_contributing_inputs( - payjoin: payjoin::receive::WantsInputs, + payjoin: payjoin::receive::v1::WantsInputs, bitcoind: &bitcoincore_rpc::Client, -) -> Result { +) -> Result { let candidate_inputs = bitcoind .list_unspent(None, None, None, None, None) .context("Failed to list unspent from bitcoind")? diff --git a/payjoin/src/receive/mod.rs b/payjoin/src/receive/mod.rs index 4f615958..b8154b1b 100644 --- a/payjoin/src/receive/mod.rs +++ b/payjoin/src/receive/mod.rs @@ -1,58 +1,16 @@ -//! Receive Payjoin -//! -//! This module contains types and methods used to receive payjoin via BIP78. -//! Usage is pretty simple: -//! -//! 1. Generate a pj_uri [BIP 21](https://github.com/bitcoin/bips/blob/master/bip-0021.mediawiki) -//! using [`build_v1_pj_uri`] -//! 2. Listen for a sender's request on the `pj` endpoint -//! 3. Parse the request using -//! [`UncheckedProposal::from_request()`](crate::receive::UncheckedProposal::from_request()) -//! 4. Validate the proposal using the `check` methods to guide you. -//! 5. Assuming the proposal is valid, augment it into a payjoin with the available -//! `try_preserving_privacy` and `contribute` methods -//! 6. Extract the payjoin PSBT and sign it -//! 7. Respond to the sender's http request with the signed PSBT as payload. -//! -//! The `receive` feature provides all of the check methods, PSBT data manipulation, coin -//! selection, and transport structures to receive payjoin and handle errors in a privacy -//! preserving way. -//! -//! Receiving payjoin entails listening to a secure http endpoint for inbound requests. The -//! endpoint is displayed in the `pj` parameter of a [bip -//! 21](https://github.com/bitcoin/bips/blob/master/bip-0021.mediawiki) request URI. -//! -//! [reference implementation](https://github.com/payjoin/rust-payjoin/tree/master/payjoin-cli) - -use std::cmp::{max, min}; - -use bitcoin::base64::prelude::BASE64_STANDARD; -use bitcoin::base64::Engine; -use bitcoin::psbt::Psbt; -use bitcoin::{psbt, AddressType, Amount, FeeRate, OutPoint, Script, TxIn, TxOut, Weight}; - -mod error; -mod optional_parameters; -#[cfg(feature = "v2")] -pub mod v2; - -use bitcoin::secp256k1::rand::seq::SliceRandom; -use bitcoin::secp256k1::rand::{self, Rng}; +use bitcoin::{psbt, AddressType, TxIn, TxOut}; pub use error::{ Error, InputContributionError, OutputSubstitutionError, RequestError, SelectionError, }; -use error::{ - InternalInputContributionError, InternalOutputSubstitutionError, InternalRequestError, - InternalSelectionError, -}; -use optional_parameters::Params; pub use crate::psbt::PsbtInputError; -use crate::psbt::{InternalInputPair, InternalPsbtInputError, PsbtExt}; +use crate::psbt::{InternalInputPair, InternalPsbtInputError}; -pub trait Headers { - fn get_header(&self, key: &str) -> Option<&str>; -} +mod error; +mod optional_parameters; +pub mod v1; +#[cfg(feature = "v2")] +pub mod v2; /// Helper to construct a pair of (txin, psbtin) with some built-in validation /// Use with [`InputPair::new`] to contribute receiver inputs. @@ -91,1023 +49,3 @@ impl InputPair { impl<'a> From<&'a InputPair> for InternalInputPair<'a> { fn from(pair: &'a InputPair) -> Self { Self { psbtin: &pair.psbtin, txin: &pair.txin } } } - -pub fn build_v1_pj_uri<'a>( - address: &bitcoin::Address, - endpoint: &url::Url, - disable_output_substitution: bool, -) -> crate::uri::PjUri<'a> { - let extras = - crate::uri::PayjoinExtras { endpoint: endpoint.clone(), disable_output_substitution }; - bitcoin_uri::Uri::with_extras(address.clone(), extras) -} - -/// The sender's original PSBT and optional parameters -/// -/// This type is used to process the request. It is returned by -/// [`UncheckedProposal::from_request()`](crate::receive::UncheckedProposal::from_request()). -/// -/// If you are implementing an interactive payment processor, you should get extract the original -/// transaction with extract_tx_to_schedule_broadcast() and schedule, followed by checking -/// that the transaction can be broadcast with check_broadcast_suitability. Otherwise it is safe to -/// call assume_interactive_receive to proceed with validation. -#[derive(Debug, Clone)] -pub struct UncheckedProposal { - psbt: Psbt, - params: Params, -} - -impl UncheckedProposal { - pub fn from_request( - mut body: impl std::io::Read, - query: &str, - headers: impl Headers, - ) -> Result { - let content_type = headers - .get_header("content-type") - .ok_or(InternalRequestError::MissingHeader("Content-Type"))?; - if !content_type.starts_with("text/plain") { - return Err(InternalRequestError::InvalidContentType(content_type.to_owned()).into()); - } - let content_length = headers - .get_header("content-length") - .ok_or(InternalRequestError::MissingHeader("Content-Length"))? - .parse::() - .map_err(InternalRequestError::InvalidContentLength)?; - // 4M block size limit with base64 encoding overhead => maximum reasonable size of content-length - if content_length > 4_000_000 * 4 / 3 { - return Err(InternalRequestError::ContentLengthTooLarge(content_length).into()); - } - - // enforce the limit - let mut buf = vec![0; content_length as usize]; // 4_000_000 * 4 / 3 fits in u32 - body.read_exact(&mut buf).map_err(InternalRequestError::Io)?; - let base64 = BASE64_STANDARD.decode(&buf).map_err(InternalRequestError::Base64)?; - let unchecked_psbt = Psbt::deserialize(&base64).map_err(InternalRequestError::Psbt)?; - - let psbt = unchecked_psbt.validate().map_err(InternalRequestError::InconsistentPsbt)?; - log::debug!("Received original psbt: {:?}", psbt); - - let pairs = url::form_urlencoded::parse(query.as_bytes()); - let params = Params::from_query_pairs(pairs).map_err(InternalRequestError::SenderParams)?; - log::debug!("Received request with params: {:?}", params); - - // TODO check that params are valid for the request's Original PSBT - - Ok(UncheckedProposal { psbt, params }) - } - - /// The Sender's Original PSBT transaction - pub fn extract_tx_to_schedule_broadcast(&self) -> bitcoin::Transaction { - self.psbt.clone().extract_tx_unchecked_fee_rate() - } - - fn psbt_fee_rate(&self) -> Result { - let original_psbt_fee = self.psbt.fee().map_err(InternalRequestError::Psbt)?; - Ok(original_psbt_fee / self.extract_tx_to_schedule_broadcast().weight()) - } - - /// Check that the Original PSBT can be broadcasted. - /// - /// Receiver MUST check that the Original PSBT from the sender - /// can be broadcast, i.e. `testmempoolaccept` bitcoind rpc returns { "allowed": true,.. }. - /// - /// Receiver can optionaly set a minimum feerate that will be enforced on the Original PSBT. - /// This can be used to prevent probing attacks and make it easier to deal with - /// high feerate environments. - /// - /// Do this check if you generate bitcoin uri to receive Payjoin on sender request without manual human approval, like a payment processor. - /// Such so called "non-interactive" receivers are otherwise vulnerable to probing attacks. - /// If a sender can make requests at will, they can learn which bitcoin the receiver owns at no cost. - /// Broadcasting the Original PSBT after some time in the failure case makes incurs sender cost and prevents probing. - /// - /// Call this after checking downstream. - pub fn check_broadcast_suitability( - self, - min_fee_rate: Option, - can_broadcast: impl Fn(&bitcoin::Transaction) -> Result, - ) -> Result { - let original_psbt_fee_rate = self.psbt_fee_rate()?; - if let Some(min_fee_rate) = min_fee_rate { - if original_psbt_fee_rate < min_fee_rate { - return Err(InternalRequestError::PsbtBelowFeeRate( - original_psbt_fee_rate, - min_fee_rate, - ) - .into()); - } - } - if can_broadcast(&self.psbt.clone().extract_tx_unchecked_fee_rate())? { - Ok(MaybeInputsOwned { psbt: self.psbt, params: self.params }) - } else { - Err(InternalRequestError::OriginalPsbtNotBroadcastable.into()) - } - } - - /// Call this method if the only way to initiate a Payjoin with this receiver - /// requires manual intervention, as in most consumer wallets. - /// - /// So-called "non-interactive" receivers, like payment processors, that allow arbitrary requests are otherwise vulnerable to probing attacks. - /// Those receivers call `extract_tx_to_check_broadcast()` and `attest_tested_and_scheduled_broadcast()` after making those checks downstream. - pub fn assume_interactive_receiver(self) -> MaybeInputsOwned { - MaybeInputsOwned { psbt: self.psbt, params: self.params } - } -} - -/// Typestate to validate that the Original PSBT has no receiver-owned inputs. -/// -/// Call [`Self::check_inputs_not_owned`] to proceed. -#[derive(Debug, Clone)] -pub struct MaybeInputsOwned { - psbt: Psbt, - params: Params, -} - -impl MaybeInputsOwned { - /// Check that the Original PSBT has no receiver-owned inputs. - /// Return original-psbt-rejected error or otherwise refuse to sign undesirable inputs. - /// - /// An attacker could try to spend receiver's own inputs. This check prevents that. - pub fn check_inputs_not_owned( - self, - is_owned: impl Fn(&Script) -> Result, - ) -> Result { - let mut err = Ok(()); - if let Some(e) = self - .psbt - .input_pairs() - .scan(&mut err, |err, input| match input.previous_txout() { - Ok(txout) => Some(txout.script_pubkey.to_owned()), - Err(e) => { - **err = Err(Error::BadRequest(InternalRequestError::PrevTxOut(e).into())); - None - } - }) - .find_map(|script| match is_owned(&script) { - Ok(false) => None, - Ok(true) => - Some(Error::BadRequest(InternalRequestError::InputOwned(script).into())), - Err(e) => Some(Error::Server(e.into())), - }) - { - return Err(e); - } - err?; - - Ok(MaybeInputsSeen { psbt: self.psbt, params: self.params }) - } -} - -/// Typestate to validate that the Original PSBT has no inputs that have been seen before. -/// -/// Call [`Self::check_no_inputs_seen_before`] to proceed. -#[derive(Debug, Clone)] -pub struct MaybeInputsSeen { - psbt: Psbt, - params: Params, -} -impl MaybeInputsSeen { - /// Make sure that the original transaction inputs have never been seen before. - /// This prevents probing attacks. This prevents reentrant Payjoin, where a sender - /// proposes a Payjoin PSBT as a new Original PSBT for a new Payjoin. - pub fn check_no_inputs_seen_before( - self, - is_known: impl Fn(&OutPoint) -> Result, - ) -> Result { - self.psbt.input_pairs().try_for_each(|input| { - match is_known(&input.txin.previous_output) { - Ok(false) => Ok::<(), Error>(()), - Ok(true) => { - log::warn!("Request contains an input we've seen before: {}. Preventing possible probing attack.", input.txin.previous_output); - Err(Error::BadRequest( - InternalRequestError::InputSeen(input.txin.previous_output).into(), - ))? - }, - Err(e) => Err(Error::Server(e.into()))?, - } - })?; - - Ok(OutputsUnknown { psbt: self.psbt, params: self.params }) - } -} - -/// The receiver has not yet identified which outputs belong to the receiver. -/// -/// Only accept PSBTs that send us money. -/// Identify those outputs with [`Self::identify_receiver_outputs`] to proceed. -#[derive(Debug, Clone)] -pub struct OutputsUnknown { - psbt: Psbt, - params: Params, -} - -impl OutputsUnknown { - /// Find which outputs belong to the receiver - pub fn identify_receiver_outputs( - self, - is_receiver_output: impl Fn(&Script) -> Result, - ) -> Result { - let owned_vouts: Vec = self - .psbt - .unsigned_tx - .output - .iter() - .enumerate() - .filter_map(|(vout, txo)| match is_receiver_output(&txo.script_pubkey) { - Ok(true) => Some(Ok(vout)), - Ok(false) => None, - Err(e) => Some(Err(e)), - }) - .collect::, _>>()?; - - if owned_vouts.is_empty() { - return Err(Error::BadRequest(InternalRequestError::MissingPayment.into())); - } - - let mut params = self.params.clone(); - if let Some((_, additional_fee_output_index)) = params.additional_fee_contribution { - // If the additional fee output index specified by the sender is pointing to a receiver output, - // the receiver should ignore the parameter. - if owned_vouts.contains(&additional_fee_output_index) { - params.additional_fee_contribution = None; - } - } - - Ok(WantsOutputs { - original_psbt: self.psbt.clone(), - payjoin_psbt: self.psbt, - params, - change_vout: owned_vouts[0], - owned_vouts, - }) - } -} - -/// A checked proposal that the receiver may substitute or add outputs to -/// -/// Call [`Self::commit_outputs`] to proceed. -#[derive(Debug, Clone)] -pub struct WantsOutputs { - original_psbt: Psbt, - payjoin_psbt: Psbt, - params: Params, - change_vout: usize, - owned_vouts: Vec, -} - -impl WantsOutputs { - pub fn is_output_substitution_disabled(&self) -> bool { - self.params.disable_output_substitution - } - - /// Substitute the receiver output script with the provided script. - pub fn substitute_receiver_script( - self, - output_script: &Script, - ) -> Result { - let output_value = self.original_psbt.unsigned_tx.output[self.change_vout].value; - let outputs = vec![TxOut { value: output_value, script_pubkey: output_script.into() }]; - self.replace_receiver_outputs(outputs, output_script) - } - - /// Replace **all** receiver outputs with one or more provided outputs. - /// The drain script specifies which address to *drain* coins to. An output corresponding to - /// that address must be included in `replacement_outputs`. The value of that output may be - /// increased or decreased depending on the receiver's input contributions and whether the - /// receiver needs to pay for additional miner fees (e.g. in the case of adding many outputs). - pub fn replace_receiver_outputs( - self, - replacement_outputs: Vec, - drain_script: &Script, - ) -> Result { - let mut payjoin_psbt = self.original_psbt.clone(); - let mut outputs = vec![]; - let mut replacement_outputs = replacement_outputs.clone(); - let mut rng = rand::thread_rng(); - // Substitute the existing receiver outputs, keeping the sender/receiver output ordering - for (i, original_output) in self.original_psbt.unsigned_tx.output.iter().enumerate() { - if self.owned_vouts.contains(&i) { - // Receiver output: substitute in-place a provided replacement output - if replacement_outputs.is_empty() { - return Err(InternalOutputSubstitutionError::NotEnoughOutputs.into()); - } - match replacement_outputs - .iter() - .position(|txo| txo.script_pubkey == original_output.script_pubkey) - { - // Select an output with the same address if one was provided - Some(pos) => { - let txo = replacement_outputs.swap_remove(pos); - if self.params.disable_output_substitution - && txo.value < original_output.value - { - return Err( - InternalOutputSubstitutionError::OutputSubstitutionDisabled( - "Decreasing the receiver output value is not allowed", - ) - .into(), - ); - } - outputs.push(txo); - } - // Otherwise randomly select one of the replacement outputs - None => { - if self.params.disable_output_substitution { - return Err( - InternalOutputSubstitutionError::OutputSubstitutionDisabled( - "Changing the receiver output script pubkey is not allowed", - ) - .into(), - ); - } - let index = rng.gen_range(0..replacement_outputs.len()); - let txo = replacement_outputs.swap_remove(index); - outputs.push(txo); - } - } - } else { - // Sender output: leave it as is - outputs.push(original_output.clone()); - } - } - // Insert all remaining outputs at random indices for privacy - interleave_shuffle(&mut outputs, &mut replacement_outputs, &mut rng); - // Identify the receiver output that will be used for change and fees - let change_vout = outputs.iter().position(|txo| txo.script_pubkey == *drain_script); - // Update the payjoin PSBT outputs - payjoin_psbt.outputs = vec![Default::default(); outputs.len()]; - payjoin_psbt.unsigned_tx.output = outputs; - Ok(WantsOutputs { - original_psbt: self.original_psbt, - payjoin_psbt, - params: self.params, - change_vout: change_vout.ok_or(InternalOutputSubstitutionError::InvalidDrainScript)?, - owned_vouts: self.owned_vouts, - }) - } - - /// Proceed to the input contribution step. - /// Outputs cannot be modified after this function is called. - pub fn commit_outputs(self) -> WantsInputs { - WantsInputs { - original_psbt: self.original_psbt, - payjoin_psbt: self.payjoin_psbt, - params: self.params, - change_vout: self.change_vout, - } - } -} - -/// Shuffles `new` vector, then interleaves its elements with those from `original`, -/// maintaining the relative order in `original` but randomly inserting elements from `new`. -/// The combined result replaces the contents of `original`. -fn interleave_shuffle(original: &mut Vec, new: &mut [T], rng: &mut R) { - // Shuffle the substitute_outputs - new.shuffle(rng); - // Create a new vector to store the combined result - let mut combined = Vec::with_capacity(original.len() + new.len()); - // Initialize indices - let mut original_index = 0; - let mut new_index = 0; - // Interleave elements - while original_index < original.len() || new_index < new.len() { - if original_index < original.len() && (new_index >= new.len() || rng.gen_bool(0.5)) { - combined.push(original[original_index].clone()); - original_index += 1; - } else { - combined.push(new[new_index].clone()); - new_index += 1; - } - } - *original = combined; -} - -/// A checked proposal that the receiver may contribute inputs to to make a payjoin -/// -/// Call [`Self::commit_inputs`] to proceed. -#[derive(Debug, Clone)] -pub struct WantsInputs { - original_psbt: Psbt, - payjoin_psbt: Psbt, - params: Params, - change_vout: usize, -} - -impl WantsInputs { - /// Select receiver input such that the payjoin avoids surveillance. - /// Return the input chosen that has been applied to the Proposal. - /// - /// Proper coin selection allows payjoin to resemble ordinary transactions. - /// To ensure the resemblance, a number of heuristics must be avoided. - /// - /// UIH "Unnecessary input heuristic" is avoided for multi-output transactions. - /// A simple consolidation is otherwise chosen if available. - pub fn try_preserving_privacy( - &self, - candidate_inputs: impl IntoIterator, - ) -> Result { - let mut candidate_inputs = candidate_inputs.into_iter().peekable(); - if candidate_inputs.peek().is_none() { - return Err(InternalSelectionError::Empty.into()); - } - - if self.payjoin_psbt.outputs.len() > 2 { - // This UIH avoidance function supports only - // many-input, n-output transactions such that n <= 2 for now - return Err(InternalSelectionError::TooManyOutputs.into()); - } - - if self.payjoin_psbt.outputs.len() == 2 { - self.avoid_uih(candidate_inputs) - } else { - self.select_first_candidate(candidate_inputs) - } - } - - /// UIH "Unnecessary input heuristic" is one class of heuristics to avoid. We define - /// UIH1 and UIH2 according to the BlockSci practice - /// BlockSci UIH1 and UIH2: - /// if min(in) > min(out) then UIH1 else UIH2 - /// - fn avoid_uih( - &self, - candidate_inputs: impl IntoIterator, - ) -> Result { - let min_out_sats = self - .payjoin_psbt - .unsigned_tx - .output - .iter() - .map(|output| output.value) - .min() - .unwrap_or(Amount::MAX_MONEY); - - let min_in_sats = self - .payjoin_psbt - .input_pairs() - .filter_map(|input| input.previous_txout().ok().map(|txo| txo.value)) - .min() - .unwrap_or(Amount::MAX_MONEY); - - let prior_payment_sats = self.payjoin_psbt.unsigned_tx.output[self.change_vout].value; - - for input_pair in candidate_inputs { - let candidate_sats = input_pair.previous_txout().value; - let candidate_min_out = min(min_out_sats, prior_payment_sats + candidate_sats); - let candidate_min_in = min(min_in_sats, candidate_sats); - - if candidate_min_in > candidate_min_out { - // The candidate avoids UIH2 but conforms to UIH1: Optimal change heuristic. - // It implies the smallest output is the sender's change address. - return Ok(input_pair); - } - } - - // No suitable privacy preserving selection found - Err(InternalSelectionError::NotFound.into()) - } - - fn select_first_candidate( - &self, - candidate_inputs: impl IntoIterator, - ) -> Result { - candidate_inputs.into_iter().next().ok_or(InternalSelectionError::NotFound.into()) - } - - /// Add the provided list of inputs to the transaction. - /// Any excess input amount is added to the change_vout output indicated previously. - pub fn contribute_inputs( - self, - inputs: impl IntoIterator, - ) -> Result { - let mut payjoin_psbt = self.payjoin_psbt.clone(); - // The payjoin proposal must not introduce mixed input sequence numbers - let original_sequence = self - .original_psbt - .unsigned_tx - .input - .first() - .map(|input| input.sequence) - .unwrap_or_default(); - let uniform_sender_input_type = self.uniform_sender_input_type()?; - - // Insert contributions at random indices for privacy - let mut rng = rand::thread_rng(); - let mut receiver_input_amount = Amount::ZERO; - for input_pair in inputs.into_iter() { - let input_type = input_pair.address_type(); - if self.params.v == 1 { - // v1 payjoin proposals must not introduce mixed input script types - self.check_mixed_input_types(input_type, uniform_sender_input_type)?; - } - - receiver_input_amount += input_pair.previous_txout().value; - let index = rng.gen_range(0..=self.payjoin_psbt.unsigned_tx.input.len()); - payjoin_psbt.inputs.insert(index, input_pair.psbtin); - payjoin_psbt - .unsigned_tx - .input - .insert(index, TxIn { sequence: original_sequence, ..input_pair.txin }); - } - - // Add the receiver change amount to the receiver change output, if applicable - let receiver_min_input_amount = self.receiver_min_input_amount(); - if receiver_input_amount >= receiver_min_input_amount { - let change_amount = receiver_input_amount - receiver_min_input_amount; - payjoin_psbt.unsigned_tx.output[self.change_vout].value += change_amount; - } else { - return Err(InternalInputContributionError::ValueTooLow.into()); - } - - Ok(WantsInputs { - original_psbt: self.original_psbt, - payjoin_psbt, - params: self.params, - change_vout: self.change_vout, - }) - } - - /// Check for mixed input types and throw an error if conditions are met - fn check_mixed_input_types( - &self, - receiver_input_type: bitcoin::AddressType, - uniform_sender_input_type: Option, - ) -> Result<(), InputContributionError> { - if let Some(uniform_sender_input_type) = uniform_sender_input_type { - if receiver_input_type != uniform_sender_input_type { - return Err(InternalInputContributionError::MixedInputScripts( - receiver_input_type, - uniform_sender_input_type, - ) - .into()); - } - } - Ok(()) - } - - /// Check if the sender's inputs are all of the same type - /// - /// Returns `None` if the sender inputs are not all of the same type - fn uniform_sender_input_type( - &self, - ) -> Result, InputContributionError> { - let mut sender_inputs = self.original_psbt.input_pairs(); - let first_input_type = sender_inputs - .next() - .ok_or(InternalInputContributionError::NoSenderInputs)? - .address_type() - .map_err(InternalInputContributionError::AddressType)?; - for input in sender_inputs { - if input.address_type().map_err(InternalInputContributionError::AddressType)? - != first_input_type - { - return Ok(None); - } - } - Ok(Some(first_input_type)) - } - - // Compute the minimum amount that the receiver must contribute to the transaction as input - fn receiver_min_input_amount(&self) -> Amount { - let output_amount = self - .payjoin_psbt - .unsigned_tx - .output - .iter() - .fold(Amount::ZERO, |acc, output| acc + output.value); - let original_output_amount = self - .original_psbt - .unsigned_tx - .output - .iter() - .fold(Amount::ZERO, |acc, output| acc + output.value); - output_amount.checked_sub(original_output_amount).unwrap_or(Amount::ZERO) - } - - /// Proceed to the proposal finalization step. - /// Inputs cannot be modified after this function is called. - pub fn commit_inputs(self) -> ProvisionalProposal { - ProvisionalProposal { - original_psbt: self.original_psbt, - payjoin_psbt: self.payjoin_psbt, - params: self.params, - change_vout: self.change_vout, - } - } -} - -/// A checked proposal that the receiver may sign and finalize to make a proposal PSBT that the -/// sender will accept. -/// -/// Call [`Self::finalize_proposal`] to return a finalized [`PayjoinProposal`]. -#[derive(Debug, Clone)] -pub struct ProvisionalProposal { - original_psbt: Psbt, - payjoin_psbt: Psbt, - params: Params, - change_vout: usize, -} - -impl ProvisionalProposal { - /// Apply additional fee contribution now that the receiver has contributed input - /// this is kind of a "build_proposal" step before we sign and finalize and extract - /// - /// max_feerate is the maximum effective feerate that the receiver is willing to pay for their - /// own input/output contributions. A max_feerate of zero indicates that the receiver is not - /// willing to pay any additional fees. - fn apply_fee( - &mut self, - min_feerate: Option, - max_feerate: FeeRate, - ) -> Result<&Psbt, RequestError> { - let min_feerate = min_feerate.unwrap_or(FeeRate::MIN); - log::trace!("min_feerate: {:?}", min_feerate); - log::trace!("params.min_feerate: {:?}", self.params.min_feerate); - let min_feerate = max(min_feerate, self.params.min_feerate); - log::debug!("min_feerate: {:?}", min_feerate); - - // If the sender specified a fee contribution, the receiver is allowed to decrease the - // sender's fee output to pay for additional input fees. Any fees in excess of - // `max_additional_fee_contribution` must be covered by the receiver. - let input_contribution_weight = self.additional_input_weight()?; - let additional_fee = input_contribution_weight * min_feerate; - log::trace!("additional_fee: {}", additional_fee); - let mut receiver_additional_fee = additional_fee; - if additional_fee > Amount::ZERO { - log::trace!( - "self.params.additional_fee_contribution: {:?}", - self.params.additional_fee_contribution - ); - if let Some((max_additional_fee_contribution, additional_fee_output_index)) = - self.params.additional_fee_contribution - { - // Find the sender's specified output in the original psbt. - // This step is necessary because the sender output may have shifted if new - // receiver outputs were added to the payjoin psbt. - let sender_fee_output = - &self.original_psbt.unsigned_tx.output[additional_fee_output_index]; - // Find the index of that output in the payjoin psbt - let sender_fee_vout = self - .payjoin_psbt - .unsigned_tx - .output - .iter() - .position(|txo| txo.script_pubkey == sender_fee_output.script_pubkey) - .expect("Sender output is missing from payjoin PSBT"); - // Determine the additional amount that the sender will pay in fees - let sender_additional_fee = min(max_additional_fee_contribution, additional_fee); - log::trace!("sender_additional_fee: {}", sender_additional_fee); - // Remove additional miner fee from the sender's specified output - self.payjoin_psbt.unsigned_tx.output[sender_fee_vout].value -= - sender_additional_fee; - receiver_additional_fee -= sender_additional_fee; - } - } - - // The sender's fee contribution can only be used to pay for additional input weight, so - // any additional outputs must be paid for by the receiver. - let output_contribution_weight = self.additional_output_weight(); - receiver_additional_fee += output_contribution_weight * min_feerate; - log::trace!("receiver_additional_fee: {}", receiver_additional_fee); - // Ensure that the receiver does not pay more in fees - // than they would by building a separate transaction at max_feerate instead. - let max_fee = (input_contribution_weight + output_contribution_weight) * max_feerate; - log::trace!("max_fee: {}", max_fee); - if receiver_additional_fee > max_fee { - let proposed_feerate = - receiver_additional_fee / (input_contribution_weight + output_contribution_weight); - return Err(InternalRequestError::FeeTooHigh(proposed_feerate, max_feerate).into()); - } - if receiver_additional_fee > Amount::ZERO { - // Remove additional miner fee from the receiver's specified output - self.payjoin_psbt.unsigned_tx.output[self.change_vout].value -= receiver_additional_fee; - } - Ok(&self.payjoin_psbt) - } - - /// Calculate the additional input weight contributed by the receiver - fn additional_input_weight(&self) -> Result { - fn inputs_weight(psbt: &Psbt) -> Result { - psbt.input_pairs().try_fold( - Weight::ZERO, - |acc, input_pair| -> Result { - let input_weight = input_pair - .expected_input_weight() - .map_err(InternalRequestError::InputWeight)?; - Ok(acc + input_weight) - }, - ) - } - let payjoin_inputs_weight = inputs_weight(&self.payjoin_psbt)?; - let original_inputs_weight = inputs_weight(&self.original_psbt)?; - let input_contribution_weight = payjoin_inputs_weight - original_inputs_weight; - log::trace!("input_contribution_weight : {}", input_contribution_weight); - Ok(input_contribution_weight) - } - - /// Calculate the additional output weight contributed by the receiver - fn additional_output_weight(&self) -> Weight { - let payjoin_outputs_weight = self - .payjoin_psbt - .unsigned_tx - .output - .iter() - .fold(Weight::ZERO, |acc, txo| acc + txo.weight()); - let original_outputs_weight = self - .original_psbt - .unsigned_tx - .output - .iter() - .fold(Weight::ZERO, |acc, txo| acc + txo.weight()); - let output_contribution_weight = payjoin_outputs_weight - original_outputs_weight; - log::trace!("output_contribution_weight : {}", output_contribution_weight); - output_contribution_weight - } - - /// Prepare the PSBT by clearing the fields that the sender expects to be empty - fn prepare_psbt(mut self, processed_psbt: Psbt) -> Result { - self.payjoin_psbt = processed_psbt; - log::trace!("Preparing PSBT {:#?}", self.payjoin_psbt); - for output in self.payjoin_psbt.outputs_mut() { - output.bip32_derivation.clear(); - output.tap_key_origins.clear(); - output.tap_internal_key = None; - } - for input in self.payjoin_psbt.inputs_mut() { - input.bip32_derivation.clear(); - input.tap_key_origins.clear(); - input.tap_internal_key = None; - input.partial_sigs.clear(); - } - for i in self.sender_input_indexes() { - log::trace!("Clearing sender input {}", i); - self.payjoin_psbt.inputs[i].non_witness_utxo = None; - self.payjoin_psbt.inputs[i].witness_utxo = None; - self.payjoin_psbt.inputs[i].final_script_sig = None; - self.payjoin_psbt.inputs[i].final_script_witness = None; - self.payjoin_psbt.inputs[i].tap_key_sig = None; - } - - Ok(PayjoinProposal { payjoin_psbt: self.payjoin_psbt, params: self.params }) - } - - /// Return the indexes of the sender inputs - fn sender_input_indexes(&self) -> Vec { - // iterate proposal as mutable WITH the outpoint (previous_output) available too - let mut original_inputs = self.original_psbt.input_pairs().peekable(); - let mut sender_input_indexes = vec![]; - for (i, input) in self.payjoin_psbt.input_pairs().enumerate() { - if let Some(original) = original_inputs.peek() { - log::trace!( - "match previous_output: {} == {}", - input.txin.previous_output, - original.txin.previous_output - ); - if input.txin.previous_output == original.txin.previous_output { - sender_input_indexes.push(i); - original_inputs.next(); - } - } - } - sender_input_indexes - } - - /// Return a Payjoin Proposal PSBT that the sender will find acceptable. - /// - /// This attempts to calculate any network fee owed by the receiver, subtract it from their output, - /// and return a PSBT that can produce a consensus-valid transaction that the sender will accept. - /// - /// wallet_process_psbt should sign and finalize receiver inputs - pub fn finalize_proposal( - mut self, - wallet_process_psbt: impl Fn(&Psbt) -> Result, - min_feerate_sat_per_vb: Option, - max_feerate_sat_per_vb: FeeRate, - ) -> Result { - let mut psbt = self.apply_fee(min_feerate_sat_per_vb, max_feerate_sat_per_vb)?.clone(); - // Remove now-invalid sender signatures before applying the receiver signatures - for i in self.sender_input_indexes() { - log::trace!("Clearing sender input {}", i); - psbt.inputs[i].final_script_sig = None; - psbt.inputs[i].final_script_witness = None; - psbt.inputs[i].tap_key_sig = None; - } - let psbt = wallet_process_psbt(&psbt)?; - let payjoin_proposal = self.prepare_psbt(psbt)?; - Ok(payjoin_proposal) - } -} - -/// A finalized payjoin proposal, complete with fees and receiver signatures, that the sender -/// should find acceptable. -#[derive(Debug, Clone)] -pub struct PayjoinProposal { - payjoin_psbt: Psbt, - params: Params, -} - -impl PayjoinProposal { - pub fn utxos_to_be_locked(&self) -> impl '_ + Iterator { - self.payjoin_psbt.unsigned_tx.input.iter().map(|input| &input.previous_output) - } - - pub fn is_output_substitution_disabled(&self) -> bool { - self.params.disable_output_substitution - } - - pub fn psbt(&self) -> &Psbt { &self.payjoin_psbt } -} - -#[cfg(test)] -mod test { - use std::str::FromStr; - - use bitcoin::{Address, Network}; - use rand::rngs::StdRng; - use rand::SeedableRng; - - use super::*; - - struct MockHeaders { - length: String, - } - - impl MockHeaders { - #[cfg(test)] - fn new(length: u64) -> MockHeaders { MockHeaders { length: length.to_string() } } - } - - impl Headers for MockHeaders { - fn get_header(&self, key: &str) -> Option<&str> { - match key { - "content-length" => Some(&self.length), - "content-type" => Some("text/plain"), - _ => None, - } - } - } - - fn proposal_from_test_vector() -> Result { - // OriginalPSBT Test Vector from BIP - // | InputScriptType | Orginal PSBT Fee rate | maxadditionalfeecontribution | additionalfeeoutputindex| - // |-----------------|-----------------------|------------------------------|-------------------------| - // | P2SH-P2WPKH | 2 sat/vbyte | 0.00000182 | 0 | - let original_psbt = "cHNidP8BAHMCAAAAAY8nutGgJdyYGXWiBEb45Hoe9lWGbkxh/6bNiOJdCDuDAAAAAAD+////AtyVuAUAAAAAF6kUHehJ8GnSdBUOOv6ujXLrWmsJRDCHgIQeAAAAAAAXqRR3QJbbz0hnQ8IvQ0fptGn+votneofTAAAAAAEBIKgb1wUAAAAAF6kU3k4ekGHKWRNbA1rV5tR5kEVDVNCHAQcXFgAUx4pFclNVgo1WWAdN1SYNX8tphTABCGsCRzBEAiB8Q+A6dep+Rz92vhy26lT0AjZn4PRLi8Bf9qoB/CMk0wIgP/Rj2PWZ3gEjUkTlhDRNAQ0gXwTO7t9n+V14pZ6oljUBIQMVmsAaoNWHVMS02LfTSe0e388LNitPa1UQZyOihY+FFgABABYAFEb2Giu6c4KO5YW0pfw3lGp9jMUUAAA="; - - let body = original_psbt.as_bytes(); - let headers = MockHeaders::new(body.len() as u64); - UncheckedProposal::from_request( - body, - "maxadditionalfeecontribution=182&additionalfeeoutputindex=0", - headers, - ) - } - - #[test] - fn can_get_proposal_from_request() { - let proposal = proposal_from_test_vector(); - assert!(proposal.is_ok(), "OriginalPSBT should be a valid request"); - } - - #[test] - fn unchecked_proposal_unlocks_after_checks() { - let proposal = proposal_from_test_vector().unwrap(); - assert_eq!(proposal.psbt_fee_rate().unwrap().to_sat_per_vb_floor(), 2); - let mut payjoin = proposal - .assume_interactive_receiver() - .check_inputs_not_owned(|_| Ok(false)) - .expect("No inputs should be owned") - .check_no_inputs_seen_before(|_| Ok(false)) - .expect("No inputs should be seen before") - .identify_receiver_outputs(|script| { - let network = Network::Bitcoin; - Ok(Address::from_script(script, network).unwrap() - == Address::from_str("3CZZi7aWFugaCdUCS15dgrUUViupmB8bVM") - .unwrap() - .require_network(network) - .unwrap()) - }) - .expect("Receiver output should be identified") - .commit_outputs() - .commit_inputs(); - - let payjoin = payjoin.apply_fee(None, FeeRate::ZERO); - - assert!(payjoin.is_ok(), "Payjoin should be a valid PSBT"); - } - - #[test] - fn sender_specifies_excessive_feerate() { - let mut proposal = proposal_from_test_vector().unwrap(); - assert_eq!(proposal.psbt_fee_rate().unwrap().to_sat_per_vb_floor(), 2); - // Specify excessive fee rate in sender params - proposal.params.min_feerate = FeeRate::from_sat_per_vb_unchecked(1000); - // Input contribution for the receiver, from the BIP78 test vector - let proposal_psbt = Psbt::from_str("cHNidP8BAJwCAAAAAo8nutGgJdyYGXWiBEb45Hoe9lWGbkxh/6bNiOJdCDuDAAAAAAD+////jye60aAl3JgZdaIERvjkeh72VYZuTGH/ps2I4l0IO4MBAAAAAP7///8CJpW4BQAAAAAXqRQd6EnwadJ0FQ46/q6NcutaawlEMIcACT0AAAAAABepFHdAltvPSGdDwi9DR+m0af6+i2d6h9MAAAAAAAEBIICEHgAAAAAAF6kUyPLL+cphRyyI5GTUazV0hF2R2NWHAQcXFgAUX4BmVeWSTJIEwtUb5TlPS/ntohABCGsCRzBEAiBnu3tA3yWlT0WBClsXXS9j69Bt+waCs9JcjWtNjtv7VgIge2VYAaBeLPDB6HGFlpqOENXMldsJezF9Gs5amvDQRDQBIQJl1jz1tBt8hNx2owTm+4Du4isx0pmdKNMNIjjaMHFfrQAAAA==").unwrap(); - let input = InputPair { - txin: proposal_psbt.unsigned_tx.input[1].clone(), - psbtin: proposal_psbt.inputs[1].clone(), - }; - let mut payjoin = proposal - .assume_interactive_receiver() - .check_inputs_not_owned(|_| Ok(false)) - .expect("No inputs should be owned") - .check_no_inputs_seen_before(|_| Ok(false)) - .expect("No inputs should be seen before") - .identify_receiver_outputs(|script| { - let network = Network::Bitcoin; - Ok(Address::from_script(script, network).unwrap() - == Address::from_str("3CZZi7aWFugaCdUCS15dgrUUViupmB8bVM") - .unwrap() - .require_network(network) - .unwrap()) - }) - .expect("Receiver output should be identified") - .commit_outputs() - .contribute_inputs(vec![input]) - .expect("Failed to contribute inputs") - .commit_inputs(); - let mut payjoin_clone = payjoin.clone(); - let psbt = payjoin.apply_fee(None, FeeRate::from_sat_per_vb_unchecked(1000)); - assert!(psbt.is_ok(), "Payjoin should be a valid PSBT"); - let psbt = payjoin_clone.apply_fee(None, FeeRate::from_sat_per_vb_unchecked(995)); - assert!(psbt.is_err(), "Payjoin exceeds receiver fee preference and should error"); - } - - #[test] - fn additional_input_weight_matches_known_weight() { - // All expected input weights pulled from: - // https://bitcoin.stackexchange.com/questions/84004/how-do-virtual-size-stripped-size-and-raw-size-compare-between-legacy-address-f#84006 - // Input weight for a single P2PKH (legacy) receiver input - let p2pkh_proposal = ProvisionalProposal { - original_psbt: Psbt::from_str("cHNidP8BAHECAAAAAb2qhegy47hqffxh/UH5Qjd/G3sBH6cW2QSXZ86nbY3nAAAAAAD9////AhXKBSoBAAAAFgAU4TiLFD14YbpddFVrZa3+Zmz96yQQJwAAAAAAABYAFB4zA2o+5MsNRT/j+0twLi5VbwO9AAAAAAABAIcCAAAAAQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA/////wMBSgD/////AgDyBSoBAAAAGXapFGUxpU6cGldVpjUm9rV2B+jTlphDiKwAAAAAAAAAACZqJKohqe3i9hw/cdHe/T+pmd+jaVN1XGkGiXmZYrSL69g2l06M+QAAAAABB2pHMEQCIGsOxO/bBv20bd68sBnEU3cxHR8OxEcUroL3ENhhjtN3AiB+9yWuBGKXu41hcfO4KP7IyLLEYc6j8hGowmAlCPCMPAEhA6WNSN4CqJ9F+42YKPlIFN0wJw7qawWbdelGRMkAbBRnACICAsdIAjsfMLKgfL2J9rfIa8yKdO1BOpSGRIFbFMBdTsc9GE4roNNUAACAAQAAgAAAAIABAAAAAAAAAAAA").unwrap(), - payjoin_psbt: Psbt::from_str("cHNidP8BAJoCAAAAAtTRxwAtk38fRMP3ffdKkIi5r+Ss9AjaO8qEv+eQ/ho3AAAAAAD9////vaqF6DLjuGp9/GH9QflCN38bewEfpxbZBJdnzqdtjecAAAAAAP3///8CgckFKgEAAAAWABThOIsUPXhhul10VWtlrf5mbP3rJBAZBioBAAAAFgAUiDIby0wSbj1kv3MlvwoEKw3vNZUAAAAAAAEAhwIAAAABAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD/////AwFoAP////8CAPIFKgEAAAAZdqkUPXhu3I6D9R0wUpvTvvUm+VGNcNuIrAAAAAAAAAAAJmokqiGp7eL2HD9x0d79P6mZ36NpU3VcaQaJeZlitIvr2DaXToz5AAAAAAEBIgDyBSoBAAAAGXapFD14btyOg/UdMFKb0771JvlRjXDbiKwBB2pHMEQCIGzKy8QfhHoAY0+LZCpQ7ZOjyyXqaSBnr89hH3Eg/xsGAiB3n8hPRuXCX/iWtURfXoJNUFu3sLeQVFf1dDFCZPN0dAEhA8rTfrwcq6dEBSNOrUfNb8+dm7q77vCtfdOmWx0HfajRAAEAhwIAAAABAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD/////AwFKAP////8CAPIFKgEAAAAZdqkUZTGlTpwaV1WmNSb2tXYH6NOWmEOIrAAAAAAAAAAAJmokqiGp7eL2HD9x0d79P6mZ36NpU3VcaQaJeZlitIvr2DaXToz5AAAAAAAAAA==").unwrap(), - params: Params::default(), - change_vout: 0 - }; - assert_eq!( - p2pkh_proposal.additional_input_weight().expect("should calculate input weight"), - Weight::from_wu(592) - ); - - // Input weight for a single nested P2WPKH (nested segwit) receiver input - let nested_p2wpkh_proposal = ProvisionalProposal { - original_psbt: Psbt::from_str("cHNidP8BAHECAAAAAeOsT9cRWRz3te+bgmtweG1vDLkdSH4057NuoodDNPFWAAAAAAD9////AhAnAAAAAAAAFgAUtp3bPFM/YWThyxD5Cc9OR4mb8tdMygUqAQAAABYAFODlplDoE6EGlZvmqoUngBgsu8qCAAAAAAABAIUCAAAAAQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA/////wMBZwD/////AgDyBSoBAAAAF6kU2JnIn4Mmcb5kuF3EYeFei8IB43qHAAAAAAAAAAAmaiSqIant4vYcP3HR3v0/qZnfo2lTdVxpBol5mWK0i+vYNpdOjPkAAAAAAQEgAPIFKgEAAAAXqRTYmcifgyZxvmS4XcRh4V6LwgHjeocBBxcWABSPGoPK1yl60X4Z9OfA7IQPUWCgVwEIawJHMEQCICZG3s2cbulPnLTvK4TwlKhsC+cem8tD2GjZZ3eMJD7FAiADh/xwv0ib8ksOrj1M27DYLiw7WFptxkMkE2YgiNMRVgEhAlDMm5DA8kU+QGiPxEWUyV1S8+XGzUOepUOck257ZOhkAAAiAgP+oMbeca66mt+UtXgHm6v/RIFEpxrwG7IvPDim5KWHpBgfVHrXVAAAgAEAAIAAAACAAQAAAAAAAAAA").unwrap(), - payjoin_psbt: Psbt::from_str("cHNidP8BAJoCAAAAAuXYOTUaVRiB8cPPhEXzcJ72/SgZOPEpPx5pkG0fNeGCAAAAAAD9////46xP1xFZHPe175uCa3B4bW8MuR1IfjTns26ih0M08VYAAAAAAP3///8CEBkGKgEAAAAWABQHuuu4H4fbQWV51IunoJLUtmMTfEzKBSoBAAAAFgAU4OWmUOgToQaVm+aqhSeAGCy7yoIAAAAAAAEBIADyBSoBAAAAF6kUQ4BssmVBS3r0s95c6dl1DQCHCR+HAQQWABQbDc333XiiOeEXroP523OoYNb1aAABAIUCAAAAAQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA/////wMBZwD/////AgDyBSoBAAAAF6kU2JnIn4Mmcb5kuF3EYeFei8IB43qHAAAAAAAAAAAmaiSqIant4vYcP3HR3v0/qZnfo2lTdVxpBol5mWK0i+vYNpdOjPkAAAAAAQEgAPIFKgEAAAAXqRTYmcifgyZxvmS4XcRh4V6LwgHjeocBBxcWABSPGoPK1yl60X4Z9OfA7IQPUWCgVwEIawJHMEQCICZG3s2cbulPnLTvK4TwlKhsC+cem8tD2GjZZ3eMJD7FAiADh/xwv0ib8ksOrj1M27DYLiw7WFptxkMkE2YgiNMRVgEhAlDMm5DA8kU+QGiPxEWUyV1S8+XGzUOepUOck257ZOhkAAAA").unwrap(), - params: Params::default(), - change_vout: 0 - }; - assert_eq!( - nested_p2wpkh_proposal - .additional_input_weight() - .expect("should calculate input weight"), - Weight::from_wu(364) - ); - - // Input weight for a single P2WPKH (native segwit) receiver input - let p2wpkh_proposal = ProvisionalProposal { - original_psbt: Psbt::from_str("cHNidP8BAHECAAAAASom13OiXZIr3bKk+LtUndZJYqdHQQU8dMs1FZ93IctIAAAAAAD9////AmPKBSoBAAAAFgAU6H98YM9NE1laARQ/t9/90nFraf4QJwAAAAAAABYAFBPJFmYuJBsrIaBBp9ur98pMSKxhAAAAAAABAIQCAAAAAQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA/////wMBWwD/////AgDyBSoBAAAAFgAUjTJXmC73n+URSNdfgbS6Oa6JyQYAAAAAAAAAACZqJKohqe3i9hw/cdHe/T+pmd+jaVN1XGkGiXmZYrSL69g2l06M+QAAAAABAR8A8gUqAQAAABYAFI0yV5gu95/lEUjXX4G0ujmuickGAQhrAkcwRAIgUqbHS0difIGTRwN56z2/EiqLQFWerfJspyjuwsGSCXcCIA3IRTu8FVgniU5E4gecAMeegVnlTbTVfFyusWhQ2kVVASEDChVRm26KidHNWLdCLBTq5jspGJr+AJyyMqmUkvPkwFsAIgIDeBqmRB3ESjFWIp+wUXn/adGZU3kqWGjdkcnKpk8bAyUY94v8N1QAAIABAACAAAAAgAEAAAAAAAAAAAA=").unwrap(), - payjoin_psbt: Psbt::from_str("cHNidP8BAJoCAAAAAiom13OiXZIr3bKk+LtUndZJYqdHQQU8dMs1FZ93IctIAAAAAAD9////NG21aH8Vat3thaVmPvWDV/lvRmymFHeePcfUjlyngHIAAAAAAP3///8CH8oFKgEAAAAWABTof3xgz00TWVoBFD+33/3ScWtp/hAZBioBAAAAFgAU1mbnqky3bMxfmm0OgFaQCAs5fsoAAAAAAAEAhAIAAAABAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD/////AwFbAP////8CAPIFKgEAAAAWABSNMleYLvef5RFI11+BtLo5ronJBgAAAAAAAAAAJmokqiGp7eL2HD9x0d79P6mZ36NpU3VcaQaJeZlitIvr2DaXToz5AAAAAAEBHwDyBSoBAAAAFgAUjTJXmC73n+URSNdfgbS6Oa6JyQYAAQCEAgAAAAEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAP////8DAWcA/////wIA8gUqAQAAABYAFJFtkfHTt3y1EDMaN6CFjjNWtpCRAAAAAAAAAAAmaiSqIant4vYcP3HR3v0/qZnfo2lTdVxpBol5mWK0i+vYNpdOjPkAAAAAAQEfAPIFKgEAAAAWABSRbZHx07d8tRAzGjeghY4zVraQkQEIawJHMEQCIDTC49IB9AnItqd8zy5RDc05f2ApBAfJ5x4zYfj3bsD2AiAQvvSt5ipScHcUwdlYB9vFnEi68hmh55M5a5e+oWvxMAEhAqErVSVulFb97/r5KQryOS1Xgghff8R7AOuEnvnmslQ5AAAA").unwrap(), - params: Params::default(), - change_vout: 0 - }; - assert_eq!( - p2wpkh_proposal.additional_input_weight().expect("should calculate input weight"), - Weight::from_wu(272) - ); - - // Input weight for a single P2TR (taproot) receiver input - let p2tr_proposal = ProvisionalProposal { - original_psbt: Psbt::from_str("cHNidP8BAHECAAAAAU/CHxd1oi9Lq1xOD2GnHe0hsQdGJ2mkpYkmeasTj+w1AAAAAAD9////Am3KBSoBAAAAFgAUqJL/PDPnHeihhNhukTz8QEdZbZAQJwAAAAAAABYAFInyO0NQF7YR22Sm0YTPGm6yf19YAAAAAAABASsA8gUqAQAAACJRIGOPekNKFs9ASLj3FdlCLiou/jdPUegJGzlA111A80MAAQhCAUC3zX8eSeL8+bAo6xO0cpon83UsJdttiuwfMn/pBwub82rzMsoS6HZNXzg7hfcB3p1uj8JmqsBkZwm8k6fnU2peACICA+u+FjwmhEgWdjhEQbO49D0NG8iCYUoqhlfsj0LN7hiRGOcVI65UAACAAQAAgAAAAIABAAAAAAAAAAAA").unwrap(), - payjoin_psbt: Psbt::from_str("cHNidP8BAJoCAAAAAk/CHxd1oi9Lq1xOD2GnHe0hsQdGJ2mkpYkmeasTj+w1AAAAAAD9////Fz+ELsYp/55j6+Jl2unG9sGvpHTiSyzSORBvtu1GEB4AAAAAAP3///8CM8oFKgEAAAAWABSokv88M+cd6KGE2G6RPPxAR1ltkBAZBioBAAAAFgAU68J5imRcKy3g5JCT3bEoP9IXEn0AAAAAAAEBKwDyBSoBAAAAIlEgY496Q0oWz0BIuPcV2UIuKi7+N09R6AkbOUDXXUDzQwAAAQErAPIFKgEAAAAiUSCfbbX+FHJbzC71eEFLsMjDouMJbu8ogeR0eNoNxMM9CwEIQwFBeyOLUebV/YwpaLTpLIaTXaSiPS7Dn6o39X4nlUzQLfb6YyvCAsLA5GTxo+Zb0NUINZ8DaRyUWknOpU/Jzuwn2gEAAAA=").unwrap(), - params: Params::default(), - change_vout: 0 - }; - assert_eq!( - p2tr_proposal.additional_input_weight().expect("should calculate input weight"), - Weight::from_wu(230) - ); - } - - #[test] - fn test_interleave_shuffle() { - let mut original1 = vec![1, 2, 3]; - let mut original2 = original1.clone(); - let mut original3 = original1.clone(); - let mut new1 = vec![4, 5, 6]; - let mut new2 = new1.clone(); - let mut new3 = new1.clone(); - let mut rng1 = StdRng::seed_from_u64(123); - let mut rng2 = StdRng::seed_from_u64(234); - let mut rng3 = StdRng::seed_from_u64(345); - // Operate on the same data multiple times with different RNG seeds. - interleave_shuffle(&mut original1, &mut new1, &mut rng1); - interleave_shuffle(&mut original2, &mut new2, &mut rng2); - interleave_shuffle(&mut original3, &mut new3, &mut rng3); - // The result should be different for each seed - // and the relative ordering from `original` always preserved/ - assert_eq!(original1, vec![1, 6, 2, 5, 4, 3]); - assert_eq!(original2, vec![1, 5, 4, 2, 6, 3]); - assert_eq!(original3, vec![4, 5, 1, 2, 6, 3]); - } -} diff --git a/payjoin/src/receive/v1.rs b/payjoin/src/receive/v1.rs new file mode 100644 index 00000000..5e9afa60 --- /dev/null +++ b/payjoin/src/receive/v1.rs @@ -0,0 +1,1068 @@ +//! Receive Payjoin v1 +//! +//! This module contains types and methods used to receive payjoin via BIP78. +//! Usage is pretty simple: +//! +//! 1. Generate a pj_uri [BIP 21](https://github.com/bitcoin/bips/blob/master/bip-0021.mediawiki) +//! using [`build_v1_pj_uri`] +//! 2. Listen for a sender's request on the `pj` endpoint +//! 3. Parse the request using +//! [`UncheckedProposal::from_request()`] +//! 4. Validate the proposal using the `check` methods to guide you. +//! 5. Assuming the proposal is valid, augment it into a payjoin with the available +//! `try_preserving_privacy` and `contribute` methods +//! 6. Extract the payjoin PSBT and sign it +//! 7. Respond to the sender's http request with the signed PSBT as payload. +//! +//! The `receive` feature provides all of the check methods, PSBT data manipulation, coin +//! selection, and transport structures to receive payjoin and handle errors in a privacy +//! preserving way. +//! +//! Receiving payjoin entails listening to a secure http endpoint for inbound requests. The +//! endpoint is displayed in the `pj` parameter of a [bip +//! 21](https://github.com/bitcoin/bips/blob/master/bip-0021.mediawiki) request URI. +//! +//! [reference implementation](https://github.com/payjoin/rust-payjoin/tree/master/payjoin-cli) + +use std::cmp::{max, min}; + +use bitcoin::base64::prelude::BASE64_STANDARD; +use bitcoin::base64::Engine; +use bitcoin::psbt::Psbt; +use bitcoin::secp256k1::rand::seq::SliceRandom; +use bitcoin::secp256k1::rand::{self, Rng}; +use bitcoin::{Amount, FeeRate, OutPoint, Script, TxIn, TxOut, Weight}; + +use super::error::{ + InternalInputContributionError, InternalOutputSubstitutionError, InternalRequestError, + InternalSelectionError, +}; +use super::optional_parameters::Params; +use super::{ + Error, InputContributionError, InputPair, OutputSubstitutionError, RequestError, SelectionError, +}; +use crate::psbt::PsbtExt; + +pub trait Headers { + fn get_header(&self, key: &str) -> Option<&str>; +} + +pub fn build_v1_pj_uri<'a>( + address: &bitcoin::Address, + endpoint: &url::Url, + disable_output_substitution: bool, +) -> crate::uri::PjUri<'a> { + let extras = + crate::uri::PayjoinExtras { endpoint: endpoint.clone(), disable_output_substitution }; + bitcoin_uri::Uri::with_extras(address.clone(), extras) +} + +/// The sender's original PSBT and optional parameters +/// +/// This type is used to process the request. It is returned by +/// [`UncheckedProposal::from_request()`] +/// +/// If you are implementing an interactive payment processor, you should get extract the original +/// transaction with extract_tx_to_schedule_broadcast() and schedule, followed by checking +/// that the transaction can be broadcast with check_broadcast_suitability. Otherwise it is safe to +/// call assume_interactive_receive to proceed with validation. +#[derive(Debug, Clone)] +pub struct UncheckedProposal { + pub(crate) psbt: Psbt, + pub(crate) params: Params, +} + +impl UncheckedProposal { + pub fn from_request( + mut body: impl std::io::Read, + query: &str, + headers: impl Headers, + ) -> Result { + let content_type = headers + .get_header("content-type") + .ok_or(InternalRequestError::MissingHeader("Content-Type"))?; + if !content_type.starts_with("text/plain") { + return Err(InternalRequestError::InvalidContentType(content_type.to_owned()).into()); + } + let content_length = headers + .get_header("content-length") + .ok_or(InternalRequestError::MissingHeader("Content-Length"))? + .parse::() + .map_err(InternalRequestError::InvalidContentLength)?; + // 4M block size limit with base64 encoding overhead => maximum reasonable size of content-length + if content_length > 4_000_000 * 4 / 3 { + return Err(InternalRequestError::ContentLengthTooLarge(content_length).into()); + } + + // enforce the limit + let mut buf = vec![0; content_length as usize]; // 4_000_000 * 4 / 3 fits in u32 + body.read_exact(&mut buf).map_err(InternalRequestError::Io)?; + let base64 = BASE64_STANDARD.decode(&buf).map_err(InternalRequestError::Base64)?; + let unchecked_psbt = Psbt::deserialize(&base64).map_err(InternalRequestError::Psbt)?; + + let psbt = unchecked_psbt.validate().map_err(InternalRequestError::InconsistentPsbt)?; + log::debug!("Received original psbt: {:?}", psbt); + + let pairs = url::form_urlencoded::parse(query.as_bytes()); + let params = Params::from_query_pairs(pairs).map_err(InternalRequestError::SenderParams)?; + log::debug!("Received request with params: {:?}", params); + + // TODO check that params are valid for the request's Original PSBT + + Ok(UncheckedProposal { psbt, params }) + } + + /// The Sender's Original PSBT transaction + pub fn extract_tx_to_schedule_broadcast(&self) -> bitcoin::Transaction { + self.psbt.clone().extract_tx_unchecked_fee_rate() + } + + fn psbt_fee_rate(&self) -> Result { + let original_psbt_fee = self.psbt.fee().map_err(InternalRequestError::Psbt)?; + Ok(original_psbt_fee / self.extract_tx_to_schedule_broadcast().weight()) + } + + /// Check that the Original PSBT can be broadcasted. + /// + /// Receiver MUST check that the Original PSBT from the sender + /// can be broadcast, i.e. `testmempoolaccept` bitcoind rpc returns { "allowed": true,.. }. + /// + /// Receiver can optionaly set a minimum feerate that will be enforced on the Original PSBT. + /// This can be used to prevent probing attacks and make it easier to deal with + /// high feerate environments. + /// + /// Do this check if you generate bitcoin uri to receive Payjoin on sender request without manual human approval, like a payment processor. + /// Such so called "non-interactive" receivers are otherwise vulnerable to probing attacks. + /// If a sender can make requests at will, they can learn which bitcoin the receiver owns at no cost. + /// Broadcasting the Original PSBT after some time in the failure case makes incurs sender cost and prevents probing. + /// + /// Call this after checking downstream. + pub fn check_broadcast_suitability( + self, + min_fee_rate: Option, + can_broadcast: impl Fn(&bitcoin::Transaction) -> Result, + ) -> Result { + let original_psbt_fee_rate = self.psbt_fee_rate()?; + if let Some(min_fee_rate) = min_fee_rate { + if original_psbt_fee_rate < min_fee_rate { + return Err(InternalRequestError::PsbtBelowFeeRate( + original_psbt_fee_rate, + min_fee_rate, + ) + .into()); + } + } + if can_broadcast(&self.psbt.clone().extract_tx_unchecked_fee_rate())? { + Ok(MaybeInputsOwned { psbt: self.psbt, params: self.params }) + } else { + Err(InternalRequestError::OriginalPsbtNotBroadcastable.into()) + } + } + + /// Call this method if the only way to initiate a Payjoin with this receiver + /// requires manual intervention, as in most consumer wallets. + /// + /// So-called "non-interactive" receivers, like payment processors, that allow arbitrary requests are otherwise vulnerable to probing attacks. + /// Those receivers call `extract_tx_to_check_broadcast()` and `attest_tested_and_scheduled_broadcast()` after making those checks downstream. + pub fn assume_interactive_receiver(self) -> MaybeInputsOwned { + MaybeInputsOwned { psbt: self.psbt, params: self.params } + } +} + +/// Typestate to validate that the Original PSBT has no receiver-owned inputs. +/// +/// Call [`Self::check_inputs_not_owned`] to proceed. +#[derive(Debug, Clone)] +pub struct MaybeInputsOwned { + psbt: Psbt, + params: Params, +} + +impl MaybeInputsOwned { + /// Check that the Original PSBT has no receiver-owned inputs. + /// Return original-psbt-rejected error or otherwise refuse to sign undesirable inputs. + /// + /// An attacker could try to spend receiver's own inputs. This check prevents that. + pub fn check_inputs_not_owned( + self, + is_owned: impl Fn(&Script) -> Result, + ) -> Result { + let mut err = Ok(()); + if let Some(e) = self + .psbt + .input_pairs() + .scan(&mut err, |err, input| match input.previous_txout() { + Ok(txout) => Some(txout.script_pubkey.to_owned()), + Err(e) => { + **err = Err(Error::BadRequest(InternalRequestError::PrevTxOut(e).into())); + None + } + }) + .find_map(|script| match is_owned(&script) { + Ok(false) => None, + Ok(true) => + Some(Error::BadRequest(InternalRequestError::InputOwned(script).into())), + Err(e) => Some(Error::Server(e.into())), + }) + { + return Err(e); + } + err?; + + Ok(MaybeInputsSeen { psbt: self.psbt, params: self.params }) + } +} + +/// Typestate to validate that the Original PSBT has no inputs that have been seen before. +/// +/// Call [`Self::check_no_inputs_seen_before`] to proceed. +#[derive(Debug, Clone)] +pub struct MaybeInputsSeen { + psbt: Psbt, + params: Params, +} +impl MaybeInputsSeen { + /// Make sure that the original transaction inputs have never been seen before. + /// This prevents probing attacks. This prevents reentrant Payjoin, where a sender + /// proposes a Payjoin PSBT as a new Original PSBT for a new Payjoin. + pub fn check_no_inputs_seen_before( + self, + is_known: impl Fn(&OutPoint) -> Result, + ) -> Result { + self.psbt.input_pairs().try_for_each(|input| { + match is_known(&input.txin.previous_output) { + Ok(false) => Ok::<(), Error>(()), + Ok(true) => { + log::warn!("Request contains an input we've seen before: {}. Preventing possible probing attack.", input.txin.previous_output); + Err(Error::BadRequest( + InternalRequestError::InputSeen(input.txin.previous_output).into(), + ))? + }, + Err(e) => Err(Error::Server(e.into()))?, + } + })?; + + Ok(OutputsUnknown { psbt: self.psbt, params: self.params }) + } +} + +/// The receiver has not yet identified which outputs belong to the receiver. +/// +/// Only accept PSBTs that send us money. +/// Identify those outputs with [`Self::identify_receiver_outputs`] to proceed. +#[derive(Debug, Clone)] +pub struct OutputsUnknown { + psbt: Psbt, + params: Params, +} + +impl OutputsUnknown { + /// Find which outputs belong to the receiver + pub fn identify_receiver_outputs( + self, + is_receiver_output: impl Fn(&Script) -> Result, + ) -> Result { + let owned_vouts: Vec = self + .psbt + .unsigned_tx + .output + .iter() + .enumerate() + .filter_map(|(vout, txo)| match is_receiver_output(&txo.script_pubkey) { + Ok(true) => Some(Ok(vout)), + Ok(false) => None, + Err(e) => Some(Err(e)), + }) + .collect::, _>>()?; + + if owned_vouts.is_empty() { + return Err(Error::BadRequest(InternalRequestError::MissingPayment.into())); + } + + let mut params = self.params.clone(); + if let Some((_, additional_fee_output_index)) = params.additional_fee_contribution { + // If the additional fee output index specified by the sender is pointing to a receiver output, + // the receiver should ignore the parameter. + if owned_vouts.contains(&additional_fee_output_index) { + params.additional_fee_contribution = None; + } + } + + Ok(WantsOutputs { + original_psbt: self.psbt.clone(), + payjoin_psbt: self.psbt, + params, + change_vout: owned_vouts[0], + owned_vouts, + }) + } +} + +/// A checked proposal that the receiver may substitute or add outputs to +/// +/// Call [`Self::commit_outputs`] to proceed. +#[derive(Debug, Clone)] +pub struct WantsOutputs { + original_psbt: Psbt, + payjoin_psbt: Psbt, + params: Params, + change_vout: usize, + owned_vouts: Vec, +} + +impl WantsOutputs { + pub fn is_output_substitution_disabled(&self) -> bool { + self.params.disable_output_substitution + } + + /// Substitute the receiver output script with the provided script. + pub fn substitute_receiver_script( + self, + output_script: &Script, + ) -> Result { + let output_value = self.original_psbt.unsigned_tx.output[self.change_vout].value; + let outputs = vec![TxOut { value: output_value, script_pubkey: output_script.into() }]; + self.replace_receiver_outputs(outputs, output_script) + } + + /// Replace **all** receiver outputs with one or more provided outputs. + /// The drain script specifies which address to *drain* coins to. An output corresponding to + /// that address must be included in `replacement_outputs`. The value of that output may be + /// increased or decreased depending on the receiver's input contributions and whether the + /// receiver needs to pay for additional miner fees (e.g. in the case of adding many outputs). + pub fn replace_receiver_outputs( + self, + replacement_outputs: Vec, + drain_script: &Script, + ) -> Result { + let mut payjoin_psbt = self.original_psbt.clone(); + let mut outputs = vec![]; + let mut replacement_outputs = replacement_outputs.clone(); + let mut rng = rand::thread_rng(); + // Substitute the existing receiver outputs, keeping the sender/receiver output ordering + for (i, original_output) in self.original_psbt.unsigned_tx.output.iter().enumerate() { + if self.owned_vouts.contains(&i) { + // Receiver output: substitute in-place a provided replacement output + if replacement_outputs.is_empty() { + return Err(InternalOutputSubstitutionError::NotEnoughOutputs.into()); + } + match replacement_outputs + .iter() + .position(|txo| txo.script_pubkey == original_output.script_pubkey) + { + // Select an output with the same address if one was provided + Some(pos) => { + let txo = replacement_outputs.swap_remove(pos); + if self.params.disable_output_substitution + && txo.value < original_output.value + { + return Err( + InternalOutputSubstitutionError::OutputSubstitutionDisabled( + "Decreasing the receiver output value is not allowed", + ) + .into(), + ); + } + outputs.push(txo); + } + // Otherwise randomly select one of the replacement outputs + None => { + if self.params.disable_output_substitution { + return Err( + InternalOutputSubstitutionError::OutputSubstitutionDisabled( + "Changing the receiver output script pubkey is not allowed", + ) + .into(), + ); + } + let index = rng.gen_range(0..replacement_outputs.len()); + let txo = replacement_outputs.swap_remove(index); + outputs.push(txo); + } + } + } else { + // Sender output: leave it as is + outputs.push(original_output.clone()); + } + } + // Insert all remaining outputs at random indices for privacy + interleave_shuffle(&mut outputs, &mut replacement_outputs, &mut rng); + // Identify the receiver output that will be used for change and fees + let change_vout = outputs.iter().position(|txo| txo.script_pubkey == *drain_script); + // Update the payjoin PSBT outputs + payjoin_psbt.outputs = vec![Default::default(); outputs.len()]; + payjoin_psbt.unsigned_tx.output = outputs; + Ok(WantsOutputs { + original_psbt: self.original_psbt, + payjoin_psbt, + params: self.params, + change_vout: change_vout.ok_or(InternalOutputSubstitutionError::InvalidDrainScript)?, + owned_vouts: self.owned_vouts, + }) + } + + /// Proceed to the input contribution step. + /// Outputs cannot be modified after this function is called. + pub fn commit_outputs(self) -> WantsInputs { + WantsInputs { + original_psbt: self.original_psbt, + payjoin_psbt: self.payjoin_psbt, + params: self.params, + change_vout: self.change_vout, + } + } +} + +/// Shuffles `new` vector, then interleaves its elements with those from `original`, +/// maintaining the relative order in `original` but randomly inserting elements from `new`. +/// The combined result replaces the contents of `original`. +fn interleave_shuffle(original: &mut Vec, new: &mut [T], rng: &mut R) { + // Shuffle the substitute_outputs + new.shuffle(rng); + // Create a new vector to store the combined result + let mut combined = Vec::with_capacity(original.len() + new.len()); + // Initialize indices + let mut original_index = 0; + let mut new_index = 0; + // Interleave elements + while original_index < original.len() || new_index < new.len() { + if original_index < original.len() && (new_index >= new.len() || rng.gen_bool(0.5)) { + combined.push(original[original_index].clone()); + original_index += 1; + } else { + combined.push(new[new_index].clone()); + new_index += 1; + } + } + *original = combined; +} + +/// A checked proposal that the receiver may contribute inputs to to make a payjoin +/// +/// Call [`Self::commit_inputs`] to proceed. +#[derive(Debug, Clone)] +pub struct WantsInputs { + original_psbt: Psbt, + payjoin_psbt: Psbt, + params: Params, + change_vout: usize, +} + +impl WantsInputs { + /// Select receiver input such that the payjoin avoids surveillance. + /// Return the input chosen that has been applied to the Proposal. + /// + /// Proper coin selection allows payjoin to resemble ordinary transactions. + /// To ensure the resemblance, a number of heuristics must be avoided. + /// + /// UIH "Unnecessary input heuristic" is avoided for multi-output transactions. + /// A simple consolidation is otherwise chosen if available. + pub fn try_preserving_privacy( + &self, + candidate_inputs: impl IntoIterator, + ) -> Result { + let mut candidate_inputs = candidate_inputs.into_iter().peekable(); + if candidate_inputs.peek().is_none() { + return Err(InternalSelectionError::Empty.into()); + } + + if self.payjoin_psbt.outputs.len() > 2 { + // This UIH avoidance function supports only + // many-input, n-output transactions such that n <= 2 for now + return Err(InternalSelectionError::TooManyOutputs.into()); + } + + if self.payjoin_psbt.outputs.len() == 2 { + self.avoid_uih(candidate_inputs) + } else { + self.select_first_candidate(candidate_inputs) + } + } + + /// UIH "Unnecessary input heuristic" is one class of heuristics to avoid. We define + /// UIH1 and UIH2 according to the BlockSci practice + /// BlockSci UIH1 and UIH2: + /// if min(in) > min(out) then UIH1 else UIH2 + /// + fn avoid_uih( + &self, + candidate_inputs: impl IntoIterator, + ) -> Result { + let min_out_sats = self + .payjoin_psbt + .unsigned_tx + .output + .iter() + .map(|output| output.value) + .min() + .unwrap_or(Amount::MAX_MONEY); + + let min_in_sats = self + .payjoin_psbt + .input_pairs() + .filter_map(|input| input.previous_txout().ok().map(|txo| txo.value)) + .min() + .unwrap_or(Amount::MAX_MONEY); + + let prior_payment_sats = self.payjoin_psbt.unsigned_tx.output[self.change_vout].value; + + for input_pair in candidate_inputs { + let candidate_sats = input_pair.previous_txout().value; + let candidate_min_out = min(min_out_sats, prior_payment_sats + candidate_sats); + let candidate_min_in = min(min_in_sats, candidate_sats); + + if candidate_min_in > candidate_min_out { + // The candidate avoids UIH2 but conforms to UIH1: Optimal change heuristic. + // It implies the smallest output is the sender's change address. + return Ok(input_pair); + } + } + + // No suitable privacy preserving selection found + Err(InternalSelectionError::NotFound.into()) + } + + fn select_first_candidate( + &self, + candidate_inputs: impl IntoIterator, + ) -> Result { + candidate_inputs.into_iter().next().ok_or(InternalSelectionError::NotFound.into()) + } + + /// Add the provided list of inputs to the transaction. + /// Any excess input amount is added to the change_vout output indicated previously. + pub fn contribute_inputs( + self, + inputs: impl IntoIterator, + ) -> Result { + let mut payjoin_psbt = self.payjoin_psbt.clone(); + // The payjoin proposal must not introduce mixed input sequence numbers + let original_sequence = self + .original_psbt + .unsigned_tx + .input + .first() + .map(|input| input.sequence) + .unwrap_or_default(); + let uniform_sender_input_type = self.uniform_sender_input_type()?; + + // Insert contributions at random indices for privacy + let mut rng = rand::thread_rng(); + let mut receiver_input_amount = Amount::ZERO; + for input_pair in inputs.into_iter() { + let input_type = input_pair.address_type(); + if self.params.v == 1 { + // v1 payjoin proposals must not introduce mixed input script types + self.check_mixed_input_types(input_type, uniform_sender_input_type)?; + } + + receiver_input_amount += input_pair.previous_txout().value; + let index = rng.gen_range(0..=self.payjoin_psbt.unsigned_tx.input.len()); + payjoin_psbt.inputs.insert(index, input_pair.psbtin); + payjoin_psbt + .unsigned_tx + .input + .insert(index, TxIn { sequence: original_sequence, ..input_pair.txin }); + } + + // Add the receiver change amount to the receiver change output, if applicable + let receiver_min_input_amount = self.receiver_min_input_amount(); + if receiver_input_amount >= receiver_min_input_amount { + let change_amount = receiver_input_amount - receiver_min_input_amount; + payjoin_psbt.unsigned_tx.output[self.change_vout].value += change_amount; + } else { + return Err(InternalInputContributionError::ValueTooLow.into()); + } + + Ok(WantsInputs { + original_psbt: self.original_psbt, + payjoin_psbt, + params: self.params, + change_vout: self.change_vout, + }) + } + + /// Check for mixed input types and throw an error if conditions are met + fn check_mixed_input_types( + &self, + receiver_input_type: bitcoin::AddressType, + uniform_sender_input_type: Option, + ) -> Result<(), InputContributionError> { + if let Some(uniform_sender_input_type) = uniform_sender_input_type { + if receiver_input_type != uniform_sender_input_type { + return Err(InternalInputContributionError::MixedInputScripts( + receiver_input_type, + uniform_sender_input_type, + ) + .into()); + } + } + Ok(()) + } + + /// Check if the sender's inputs are all of the same type + /// + /// Returns `None` if the sender inputs are not all of the same type + fn uniform_sender_input_type( + &self, + ) -> Result, InputContributionError> { + let mut sender_inputs = self.original_psbt.input_pairs(); + let first_input_type = sender_inputs + .next() + .ok_or(InternalInputContributionError::NoSenderInputs)? + .address_type() + .map_err(InternalInputContributionError::AddressType)?; + for input in sender_inputs { + if input.address_type().map_err(InternalInputContributionError::AddressType)? + != first_input_type + { + return Ok(None); + } + } + Ok(Some(first_input_type)) + } + + // Compute the minimum amount that the receiver must contribute to the transaction as input + fn receiver_min_input_amount(&self) -> Amount { + let output_amount = self + .payjoin_psbt + .unsigned_tx + .output + .iter() + .fold(Amount::ZERO, |acc, output| acc + output.value); + let original_output_amount = self + .original_psbt + .unsigned_tx + .output + .iter() + .fold(Amount::ZERO, |acc, output| acc + output.value); + output_amount.checked_sub(original_output_amount).unwrap_or(Amount::ZERO) + } + + /// Proceed to the proposal finalization step. + /// Inputs cannot be modified after this function is called. + pub fn commit_inputs(self) -> ProvisionalProposal { + ProvisionalProposal { + original_psbt: self.original_psbt, + payjoin_psbt: self.payjoin_psbt, + params: self.params, + change_vout: self.change_vout, + } + } +} + +/// A checked proposal that the receiver may sign and finalize to make a proposal PSBT that the +/// sender will accept. +/// +/// Call [`Self::finalize_proposal`] to return a finalized [`PayjoinProposal`]. +#[derive(Debug, Clone)] +pub struct ProvisionalProposal { + original_psbt: Psbt, + payjoin_psbt: Psbt, + params: Params, + change_vout: usize, +} + +impl ProvisionalProposal { + /// Apply additional fee contribution now that the receiver has contributed input + /// this is kind of a "build_proposal" step before we sign and finalize and extract + /// + /// max_feerate is the maximum effective feerate that the receiver is willing to pay for their + /// own input/output contributions. A max_feerate of zero indicates that the receiver is not + /// willing to pay any additional fees. + fn apply_fee( + &mut self, + min_feerate: Option, + max_feerate: FeeRate, + ) -> Result<&Psbt, RequestError> { + let min_feerate = min_feerate.unwrap_or(FeeRate::MIN); + log::trace!("min_feerate: {:?}", min_feerate); + log::trace!("params.min_feerate: {:?}", self.params.min_feerate); + let min_feerate = max(min_feerate, self.params.min_feerate); + log::debug!("min_feerate: {:?}", min_feerate); + + // If the sender specified a fee contribution, the receiver is allowed to decrease the + // sender's fee output to pay for additional input fees. Any fees in excess of + // `max_additional_fee_contribution` must be covered by the receiver. + let input_contribution_weight = self.additional_input_weight()?; + let additional_fee = input_contribution_weight * min_feerate; + log::trace!("additional_fee: {}", additional_fee); + let mut receiver_additional_fee = additional_fee; + if additional_fee > Amount::ZERO { + log::trace!( + "self.params.additional_fee_contribution: {:?}", + self.params.additional_fee_contribution + ); + if let Some((max_additional_fee_contribution, additional_fee_output_index)) = + self.params.additional_fee_contribution + { + // Find the sender's specified output in the original psbt. + // This step is necessary because the sender output may have shifted if new + // receiver outputs were added to the payjoin psbt. + let sender_fee_output = + &self.original_psbt.unsigned_tx.output[additional_fee_output_index]; + // Find the index of that output in the payjoin psbt + let sender_fee_vout = self + .payjoin_psbt + .unsigned_tx + .output + .iter() + .position(|txo| txo.script_pubkey == sender_fee_output.script_pubkey) + .expect("Sender output is missing from payjoin PSBT"); + // Determine the additional amount that the sender will pay in fees + let sender_additional_fee = min(max_additional_fee_contribution, additional_fee); + log::trace!("sender_additional_fee: {}", sender_additional_fee); + // Remove additional miner fee from the sender's specified output + self.payjoin_psbt.unsigned_tx.output[sender_fee_vout].value -= + sender_additional_fee; + receiver_additional_fee -= sender_additional_fee; + } + } + + // The sender's fee contribution can only be used to pay for additional input weight, so + // any additional outputs must be paid for by the receiver. + let output_contribution_weight = self.additional_output_weight(); + receiver_additional_fee += output_contribution_weight * min_feerate; + log::trace!("receiver_additional_fee: {}", receiver_additional_fee); + // Ensure that the receiver does not pay more in fees + // than they would by building a separate transaction at max_feerate instead. + let max_fee = (input_contribution_weight + output_contribution_weight) * max_feerate; + log::trace!("max_fee: {}", max_fee); + if receiver_additional_fee > max_fee { + let proposed_feerate = + receiver_additional_fee / (input_contribution_weight + output_contribution_weight); + return Err(InternalRequestError::FeeTooHigh(proposed_feerate, max_feerate).into()); + } + if receiver_additional_fee > Amount::ZERO { + // Remove additional miner fee from the receiver's specified output + self.payjoin_psbt.unsigned_tx.output[self.change_vout].value -= receiver_additional_fee; + } + Ok(&self.payjoin_psbt) + } + + /// Calculate the additional input weight contributed by the receiver + fn additional_input_weight(&self) -> Result { + fn inputs_weight(psbt: &Psbt) -> Result { + psbt.input_pairs().try_fold( + Weight::ZERO, + |acc, input_pair| -> Result { + let input_weight = input_pair + .expected_input_weight() + .map_err(InternalRequestError::InputWeight)?; + Ok(acc + input_weight) + }, + ) + } + let payjoin_inputs_weight = inputs_weight(&self.payjoin_psbt)?; + let original_inputs_weight = inputs_weight(&self.original_psbt)?; + let input_contribution_weight = payjoin_inputs_weight - original_inputs_weight; + log::trace!("input_contribution_weight : {}", input_contribution_weight); + Ok(input_contribution_weight) + } + + /// Calculate the additional output weight contributed by the receiver + fn additional_output_weight(&self) -> Weight { + let payjoin_outputs_weight = self + .payjoin_psbt + .unsigned_tx + .output + .iter() + .fold(Weight::ZERO, |acc, txo| acc + txo.weight()); + let original_outputs_weight = self + .original_psbt + .unsigned_tx + .output + .iter() + .fold(Weight::ZERO, |acc, txo| acc + txo.weight()); + let output_contribution_weight = payjoin_outputs_weight - original_outputs_weight; + log::trace!("output_contribution_weight : {}", output_contribution_weight); + output_contribution_weight + } + + /// Prepare the PSBT by clearing the fields that the sender expects to be empty + fn prepare_psbt(mut self, processed_psbt: Psbt) -> Result { + self.payjoin_psbt = processed_psbt; + log::trace!("Preparing PSBT {:#?}", self.payjoin_psbt); + for output in self.payjoin_psbt.outputs_mut() { + output.bip32_derivation.clear(); + output.tap_key_origins.clear(); + output.tap_internal_key = None; + } + for input in self.payjoin_psbt.inputs_mut() { + input.bip32_derivation.clear(); + input.tap_key_origins.clear(); + input.tap_internal_key = None; + input.partial_sigs.clear(); + } + for i in self.sender_input_indexes() { + log::trace!("Clearing sender input {}", i); + self.payjoin_psbt.inputs[i].non_witness_utxo = None; + self.payjoin_psbt.inputs[i].witness_utxo = None; + self.payjoin_psbt.inputs[i].final_script_sig = None; + self.payjoin_psbt.inputs[i].final_script_witness = None; + self.payjoin_psbt.inputs[i].tap_key_sig = None; + } + + Ok(PayjoinProposal { payjoin_psbt: self.payjoin_psbt, params: self.params }) + } + + /// Return the indexes of the sender inputs + fn sender_input_indexes(&self) -> Vec { + // iterate proposal as mutable WITH the outpoint (previous_output) available too + let mut original_inputs = self.original_psbt.input_pairs().peekable(); + let mut sender_input_indexes = vec![]; + for (i, input) in self.payjoin_psbt.input_pairs().enumerate() { + if let Some(original) = original_inputs.peek() { + log::trace!( + "match previous_output: {} == {}", + input.txin.previous_output, + original.txin.previous_output + ); + if input.txin.previous_output == original.txin.previous_output { + sender_input_indexes.push(i); + original_inputs.next(); + } + } + } + sender_input_indexes + } + + /// Return a Payjoin Proposal PSBT that the sender will find acceptable. + /// + /// This attempts to calculate any network fee owed by the receiver, subtract it from their output, + /// and return a PSBT that can produce a consensus-valid transaction that the sender will accept. + /// + /// wallet_process_psbt should sign and finalize receiver inputs + pub fn finalize_proposal( + mut self, + wallet_process_psbt: impl Fn(&Psbt) -> Result, + min_feerate_sat_per_vb: Option, + max_feerate_sat_per_vb: FeeRate, + ) -> Result { + let mut psbt = self.apply_fee(min_feerate_sat_per_vb, max_feerate_sat_per_vb)?.clone(); + // Remove now-invalid sender signatures before applying the receiver signatures + for i in self.sender_input_indexes() { + log::trace!("Clearing sender input {}", i); + psbt.inputs[i].final_script_sig = None; + psbt.inputs[i].final_script_witness = None; + psbt.inputs[i].tap_key_sig = None; + } + let psbt = wallet_process_psbt(&psbt)?; + let payjoin_proposal = self.prepare_psbt(psbt)?; + Ok(payjoin_proposal) + } +} + +/// A finalized payjoin proposal, complete with fees and receiver signatures, that the sender +/// should find acceptable. +#[derive(Debug, Clone)] +pub struct PayjoinProposal { + payjoin_psbt: Psbt, + params: Params, +} + +impl PayjoinProposal { + pub fn utxos_to_be_locked(&self) -> impl '_ + Iterator { + self.payjoin_psbt.unsigned_tx.input.iter().map(|input| &input.previous_output) + } + + pub fn is_output_substitution_disabled(&self) -> bool { + self.params.disable_output_substitution + } + + pub fn psbt(&self) -> &Psbt { &self.payjoin_psbt } +} + +#[cfg(test)] +mod test { + use std::str::FromStr; + + use bitcoin::{Address, Network}; + use rand::rngs::StdRng; + use rand::SeedableRng; + + use super::*; + + struct MockHeaders { + length: String, + } + + impl MockHeaders { + #[cfg(test)] + fn new(length: u64) -> MockHeaders { MockHeaders { length: length.to_string() } } + } + + impl Headers for MockHeaders { + fn get_header(&self, key: &str) -> Option<&str> { + match key { + "content-length" => Some(&self.length), + "content-type" => Some("text/plain"), + _ => None, + } + } + } + + fn proposal_from_test_vector() -> Result { + // OriginalPSBT Test Vector from BIP + // | InputScriptType | Orginal PSBT Fee rate | maxadditionalfeecontribution | additionalfeeoutputindex| + // |-----------------|-----------------------|------------------------------|-------------------------| + // | P2SH-P2WPKH | 2 sat/vbyte | 0.00000182 | 0 | + let original_psbt = "cHNidP8BAHMCAAAAAY8nutGgJdyYGXWiBEb45Hoe9lWGbkxh/6bNiOJdCDuDAAAAAAD+////AtyVuAUAAAAAF6kUHehJ8GnSdBUOOv6ujXLrWmsJRDCHgIQeAAAAAAAXqRR3QJbbz0hnQ8IvQ0fptGn+votneofTAAAAAAEBIKgb1wUAAAAAF6kU3k4ekGHKWRNbA1rV5tR5kEVDVNCHAQcXFgAUx4pFclNVgo1WWAdN1SYNX8tphTABCGsCRzBEAiB8Q+A6dep+Rz92vhy26lT0AjZn4PRLi8Bf9qoB/CMk0wIgP/Rj2PWZ3gEjUkTlhDRNAQ0gXwTO7t9n+V14pZ6oljUBIQMVmsAaoNWHVMS02LfTSe0e388LNitPa1UQZyOihY+FFgABABYAFEb2Giu6c4KO5YW0pfw3lGp9jMUUAAA="; + + let body = original_psbt.as_bytes(); + let headers = MockHeaders::new(body.len() as u64); + UncheckedProposal::from_request( + body, + "maxadditionalfeecontribution=182&additionalfeeoutputindex=0", + headers, + ) + } + + #[test] + fn can_get_proposal_from_request() { + let proposal = proposal_from_test_vector(); + assert!(proposal.is_ok(), "OriginalPSBT should be a valid request"); + } + + #[test] + fn unchecked_proposal_unlocks_after_checks() { + let proposal = proposal_from_test_vector().unwrap(); + assert_eq!(proposal.psbt_fee_rate().unwrap().to_sat_per_vb_floor(), 2); + let mut payjoin = proposal + .assume_interactive_receiver() + .check_inputs_not_owned(|_| Ok(false)) + .expect("No inputs should be owned") + .check_no_inputs_seen_before(|_| Ok(false)) + .expect("No inputs should be seen before") + .identify_receiver_outputs(|script| { + let network = Network::Bitcoin; + Ok(Address::from_script(script, network).unwrap() + == Address::from_str("3CZZi7aWFugaCdUCS15dgrUUViupmB8bVM") + .unwrap() + .require_network(network) + .unwrap()) + }) + .expect("Receiver output should be identified") + .commit_outputs() + .commit_inputs(); + + let payjoin = payjoin.apply_fee(None, FeeRate::ZERO); + + assert!(payjoin.is_ok(), "Payjoin should be a valid PSBT"); + } + + #[test] + fn sender_specifies_excessive_feerate() { + let mut proposal = proposal_from_test_vector().unwrap(); + assert_eq!(proposal.psbt_fee_rate().unwrap().to_sat_per_vb_floor(), 2); + // Specify excessive fee rate in sender params + proposal.params.min_feerate = FeeRate::from_sat_per_vb_unchecked(1000); + // Input contribution for the receiver, from the BIP78 test vector + let proposal_psbt = Psbt::from_str("cHNidP8BAJwCAAAAAo8nutGgJdyYGXWiBEb45Hoe9lWGbkxh/6bNiOJdCDuDAAAAAAD+////jye60aAl3JgZdaIERvjkeh72VYZuTGH/ps2I4l0IO4MBAAAAAP7///8CJpW4BQAAAAAXqRQd6EnwadJ0FQ46/q6NcutaawlEMIcACT0AAAAAABepFHdAltvPSGdDwi9DR+m0af6+i2d6h9MAAAAAAAEBIICEHgAAAAAAF6kUyPLL+cphRyyI5GTUazV0hF2R2NWHAQcXFgAUX4BmVeWSTJIEwtUb5TlPS/ntohABCGsCRzBEAiBnu3tA3yWlT0WBClsXXS9j69Bt+waCs9JcjWtNjtv7VgIge2VYAaBeLPDB6HGFlpqOENXMldsJezF9Gs5amvDQRDQBIQJl1jz1tBt8hNx2owTm+4Du4isx0pmdKNMNIjjaMHFfrQAAAA==").unwrap(); + let input = InputPair { + txin: proposal_psbt.unsigned_tx.input[1].clone(), + psbtin: proposal_psbt.inputs[1].clone(), + }; + let mut payjoin = proposal + .assume_interactive_receiver() + .check_inputs_not_owned(|_| Ok(false)) + .expect("No inputs should be owned") + .check_no_inputs_seen_before(|_| Ok(false)) + .expect("No inputs should be seen before") + .identify_receiver_outputs(|script| { + let network = Network::Bitcoin; + Ok(Address::from_script(script, network).unwrap() + == Address::from_str("3CZZi7aWFugaCdUCS15dgrUUViupmB8bVM") + .unwrap() + .require_network(network) + .unwrap()) + }) + .expect("Receiver output should be identified") + .commit_outputs() + .contribute_inputs(vec![input]) + .expect("Failed to contribute inputs") + .commit_inputs(); + let mut payjoin_clone = payjoin.clone(); + let psbt = payjoin.apply_fee(None, FeeRate::from_sat_per_vb_unchecked(1000)); + assert!(psbt.is_ok(), "Payjoin should be a valid PSBT"); + let psbt = payjoin_clone.apply_fee(None, FeeRate::from_sat_per_vb_unchecked(995)); + assert!(psbt.is_err(), "Payjoin exceeds receiver fee preference and should error"); + } + + #[test] + fn additional_input_weight_matches_known_weight() { + // All expected input weights pulled from: + // https://bitcoin.stackexchange.com/questions/84004/how-do-virtual-size-stripped-size-and-raw-size-compare-between-legacy-address-f#84006 + // Input weight for a single P2PKH (legacy) receiver input + let p2pkh_proposal = ProvisionalProposal { + original_psbt: Psbt::from_str("cHNidP8BAHECAAAAAb2qhegy47hqffxh/UH5Qjd/G3sBH6cW2QSXZ86nbY3nAAAAAAD9////AhXKBSoBAAAAFgAU4TiLFD14YbpddFVrZa3+Zmz96yQQJwAAAAAAABYAFB4zA2o+5MsNRT/j+0twLi5VbwO9AAAAAAABAIcCAAAAAQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA/////wMBSgD/////AgDyBSoBAAAAGXapFGUxpU6cGldVpjUm9rV2B+jTlphDiKwAAAAAAAAAACZqJKohqe3i9hw/cdHe/T+pmd+jaVN1XGkGiXmZYrSL69g2l06M+QAAAAABB2pHMEQCIGsOxO/bBv20bd68sBnEU3cxHR8OxEcUroL3ENhhjtN3AiB+9yWuBGKXu41hcfO4KP7IyLLEYc6j8hGowmAlCPCMPAEhA6WNSN4CqJ9F+42YKPlIFN0wJw7qawWbdelGRMkAbBRnACICAsdIAjsfMLKgfL2J9rfIa8yKdO1BOpSGRIFbFMBdTsc9GE4roNNUAACAAQAAgAAAAIABAAAAAAAAAAAA").unwrap(), + payjoin_psbt: Psbt::from_str("cHNidP8BAJoCAAAAAtTRxwAtk38fRMP3ffdKkIi5r+Ss9AjaO8qEv+eQ/ho3AAAAAAD9////vaqF6DLjuGp9/GH9QflCN38bewEfpxbZBJdnzqdtjecAAAAAAP3///8CgckFKgEAAAAWABThOIsUPXhhul10VWtlrf5mbP3rJBAZBioBAAAAFgAUiDIby0wSbj1kv3MlvwoEKw3vNZUAAAAAAAEAhwIAAAABAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD/////AwFoAP////8CAPIFKgEAAAAZdqkUPXhu3I6D9R0wUpvTvvUm+VGNcNuIrAAAAAAAAAAAJmokqiGp7eL2HD9x0d79P6mZ36NpU3VcaQaJeZlitIvr2DaXToz5AAAAAAEBIgDyBSoBAAAAGXapFD14btyOg/UdMFKb0771JvlRjXDbiKwBB2pHMEQCIGzKy8QfhHoAY0+LZCpQ7ZOjyyXqaSBnr89hH3Eg/xsGAiB3n8hPRuXCX/iWtURfXoJNUFu3sLeQVFf1dDFCZPN0dAEhA8rTfrwcq6dEBSNOrUfNb8+dm7q77vCtfdOmWx0HfajRAAEAhwIAAAABAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD/////AwFKAP////8CAPIFKgEAAAAZdqkUZTGlTpwaV1WmNSb2tXYH6NOWmEOIrAAAAAAAAAAAJmokqiGp7eL2HD9x0d79P6mZ36NpU3VcaQaJeZlitIvr2DaXToz5AAAAAAAAAA==").unwrap(), + params: Params::default(), + change_vout: 0 + }; + assert_eq!( + p2pkh_proposal.additional_input_weight().expect("should calculate input weight"), + Weight::from_wu(592) + ); + + // Input weight for a single nested P2WPKH (nested segwit) receiver input + let nested_p2wpkh_proposal = ProvisionalProposal { + original_psbt: Psbt::from_str("cHNidP8BAHECAAAAAeOsT9cRWRz3te+bgmtweG1vDLkdSH4057NuoodDNPFWAAAAAAD9////AhAnAAAAAAAAFgAUtp3bPFM/YWThyxD5Cc9OR4mb8tdMygUqAQAAABYAFODlplDoE6EGlZvmqoUngBgsu8qCAAAAAAABAIUCAAAAAQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA/////wMBZwD/////AgDyBSoBAAAAF6kU2JnIn4Mmcb5kuF3EYeFei8IB43qHAAAAAAAAAAAmaiSqIant4vYcP3HR3v0/qZnfo2lTdVxpBol5mWK0i+vYNpdOjPkAAAAAAQEgAPIFKgEAAAAXqRTYmcifgyZxvmS4XcRh4V6LwgHjeocBBxcWABSPGoPK1yl60X4Z9OfA7IQPUWCgVwEIawJHMEQCICZG3s2cbulPnLTvK4TwlKhsC+cem8tD2GjZZ3eMJD7FAiADh/xwv0ib8ksOrj1M27DYLiw7WFptxkMkE2YgiNMRVgEhAlDMm5DA8kU+QGiPxEWUyV1S8+XGzUOepUOck257ZOhkAAAiAgP+oMbeca66mt+UtXgHm6v/RIFEpxrwG7IvPDim5KWHpBgfVHrXVAAAgAEAAIAAAACAAQAAAAAAAAAA").unwrap(), + payjoin_psbt: Psbt::from_str("cHNidP8BAJoCAAAAAuXYOTUaVRiB8cPPhEXzcJ72/SgZOPEpPx5pkG0fNeGCAAAAAAD9////46xP1xFZHPe175uCa3B4bW8MuR1IfjTns26ih0M08VYAAAAAAP3///8CEBkGKgEAAAAWABQHuuu4H4fbQWV51IunoJLUtmMTfEzKBSoBAAAAFgAU4OWmUOgToQaVm+aqhSeAGCy7yoIAAAAAAAEBIADyBSoBAAAAF6kUQ4BssmVBS3r0s95c6dl1DQCHCR+HAQQWABQbDc333XiiOeEXroP523OoYNb1aAABAIUCAAAAAQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA/////wMBZwD/////AgDyBSoBAAAAF6kU2JnIn4Mmcb5kuF3EYeFei8IB43qHAAAAAAAAAAAmaiSqIant4vYcP3HR3v0/qZnfo2lTdVxpBol5mWK0i+vYNpdOjPkAAAAAAQEgAPIFKgEAAAAXqRTYmcifgyZxvmS4XcRh4V6LwgHjeocBBxcWABSPGoPK1yl60X4Z9OfA7IQPUWCgVwEIawJHMEQCICZG3s2cbulPnLTvK4TwlKhsC+cem8tD2GjZZ3eMJD7FAiADh/xwv0ib8ksOrj1M27DYLiw7WFptxkMkE2YgiNMRVgEhAlDMm5DA8kU+QGiPxEWUyV1S8+XGzUOepUOck257ZOhkAAAA").unwrap(), + params: Params::default(), + change_vout: 0 + }; + assert_eq!( + nested_p2wpkh_proposal + .additional_input_weight() + .expect("should calculate input weight"), + Weight::from_wu(364) + ); + + // Input weight for a single P2WPKH (native segwit) receiver input + let p2wpkh_proposal = ProvisionalProposal { + original_psbt: Psbt::from_str("cHNidP8BAHECAAAAASom13OiXZIr3bKk+LtUndZJYqdHQQU8dMs1FZ93IctIAAAAAAD9////AmPKBSoBAAAAFgAU6H98YM9NE1laARQ/t9/90nFraf4QJwAAAAAAABYAFBPJFmYuJBsrIaBBp9ur98pMSKxhAAAAAAABAIQCAAAAAQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA/////wMBWwD/////AgDyBSoBAAAAFgAUjTJXmC73n+URSNdfgbS6Oa6JyQYAAAAAAAAAACZqJKohqe3i9hw/cdHe/T+pmd+jaVN1XGkGiXmZYrSL69g2l06M+QAAAAABAR8A8gUqAQAAABYAFI0yV5gu95/lEUjXX4G0ujmuickGAQhrAkcwRAIgUqbHS0difIGTRwN56z2/EiqLQFWerfJspyjuwsGSCXcCIA3IRTu8FVgniU5E4gecAMeegVnlTbTVfFyusWhQ2kVVASEDChVRm26KidHNWLdCLBTq5jspGJr+AJyyMqmUkvPkwFsAIgIDeBqmRB3ESjFWIp+wUXn/adGZU3kqWGjdkcnKpk8bAyUY94v8N1QAAIABAACAAAAAgAEAAAAAAAAAAAA=").unwrap(), + payjoin_psbt: Psbt::from_str("cHNidP8BAJoCAAAAAiom13OiXZIr3bKk+LtUndZJYqdHQQU8dMs1FZ93IctIAAAAAAD9////NG21aH8Vat3thaVmPvWDV/lvRmymFHeePcfUjlyngHIAAAAAAP3///8CH8oFKgEAAAAWABTof3xgz00TWVoBFD+33/3ScWtp/hAZBioBAAAAFgAU1mbnqky3bMxfmm0OgFaQCAs5fsoAAAAAAAEAhAIAAAABAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD/////AwFbAP////8CAPIFKgEAAAAWABSNMleYLvef5RFI11+BtLo5ronJBgAAAAAAAAAAJmokqiGp7eL2HD9x0d79P6mZ36NpU3VcaQaJeZlitIvr2DaXToz5AAAAAAEBHwDyBSoBAAAAFgAUjTJXmC73n+URSNdfgbS6Oa6JyQYAAQCEAgAAAAEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAP////8DAWcA/////wIA8gUqAQAAABYAFJFtkfHTt3y1EDMaN6CFjjNWtpCRAAAAAAAAAAAmaiSqIant4vYcP3HR3v0/qZnfo2lTdVxpBol5mWK0i+vYNpdOjPkAAAAAAQEfAPIFKgEAAAAWABSRbZHx07d8tRAzGjeghY4zVraQkQEIawJHMEQCIDTC49IB9AnItqd8zy5RDc05f2ApBAfJ5x4zYfj3bsD2AiAQvvSt5ipScHcUwdlYB9vFnEi68hmh55M5a5e+oWvxMAEhAqErVSVulFb97/r5KQryOS1Xgghff8R7AOuEnvnmslQ5AAAA").unwrap(), + params: Params::default(), + change_vout: 0 + }; + assert_eq!( + p2wpkh_proposal.additional_input_weight().expect("should calculate input weight"), + Weight::from_wu(272) + ); + + // Input weight for a single P2TR (taproot) receiver input + let p2tr_proposal = ProvisionalProposal { + original_psbt: Psbt::from_str("cHNidP8BAHECAAAAAU/CHxd1oi9Lq1xOD2GnHe0hsQdGJ2mkpYkmeasTj+w1AAAAAAD9////Am3KBSoBAAAAFgAUqJL/PDPnHeihhNhukTz8QEdZbZAQJwAAAAAAABYAFInyO0NQF7YR22Sm0YTPGm6yf19YAAAAAAABASsA8gUqAQAAACJRIGOPekNKFs9ASLj3FdlCLiou/jdPUegJGzlA111A80MAAQhCAUC3zX8eSeL8+bAo6xO0cpon83UsJdttiuwfMn/pBwub82rzMsoS6HZNXzg7hfcB3p1uj8JmqsBkZwm8k6fnU2peACICA+u+FjwmhEgWdjhEQbO49D0NG8iCYUoqhlfsj0LN7hiRGOcVI65UAACAAQAAgAAAAIABAAAAAAAAAAAA").unwrap(), + payjoin_psbt: Psbt::from_str("cHNidP8BAJoCAAAAAk/CHxd1oi9Lq1xOD2GnHe0hsQdGJ2mkpYkmeasTj+w1AAAAAAD9////Fz+ELsYp/55j6+Jl2unG9sGvpHTiSyzSORBvtu1GEB4AAAAAAP3///8CM8oFKgEAAAAWABSokv88M+cd6KGE2G6RPPxAR1ltkBAZBioBAAAAFgAU68J5imRcKy3g5JCT3bEoP9IXEn0AAAAAAAEBKwDyBSoBAAAAIlEgY496Q0oWz0BIuPcV2UIuKi7+N09R6AkbOUDXXUDzQwAAAQErAPIFKgEAAAAiUSCfbbX+FHJbzC71eEFLsMjDouMJbu8ogeR0eNoNxMM9CwEIQwFBeyOLUebV/YwpaLTpLIaTXaSiPS7Dn6o39X4nlUzQLfb6YyvCAsLA5GTxo+Zb0NUINZ8DaRyUWknOpU/Jzuwn2gEAAAA=").unwrap(), + params: Params::default(), + change_vout: 0 + }; + assert_eq!( + p2tr_proposal.additional_input_weight().expect("should calculate input weight"), + Weight::from_wu(230) + ); + } + + #[test] + fn test_interleave_shuffle() { + let mut original1 = vec![1, 2, 3]; + let mut original2 = original1.clone(); + let mut original3 = original1.clone(); + let mut new1 = vec![4, 5, 6]; + let mut new2 = new1.clone(); + let mut new3 = new1.clone(); + let mut rng1 = StdRng::seed_from_u64(123); + let mut rng2 = StdRng::seed_from_u64(234); + let mut rng3 = StdRng::seed_from_u64(345); + // Operate on the same data multiple times with different RNG seeds. + interleave_shuffle(&mut original1, &mut new1, &mut rng1); + interleave_shuffle(&mut original2, &mut new2, &mut rng2); + interleave_shuffle(&mut original3, &mut new3, &mut rng3); + // The result should be different for each seed + // and the relative ordering from `original` always preserved/ + assert_eq!(original1, vec![1, 6, 2, 5, 4, 3]); + assert_eq!(original2, vec![1, 5, 4, 2, 6, 3]); + assert_eq!(original3, vec![4, 5, 1, 2, 6, 3]); + } +} diff --git a/payjoin/src/receive/v2/mod.rs b/payjoin/src/receive/v2/mod.rs index c3d2a9c7..f0a51fc8 100644 --- a/payjoin/src/receive/v2/mod.rs +++ b/payjoin/src/receive/v2/mod.rs @@ -8,10 +8,10 @@ use serde::de::Deserializer; use serde::{Deserialize, Serialize}; use url::Url; +use super::error::InternalRequestError; use super::v2::error::{InternalSessionError, SessionError}; use super::{ - Error, InputContributionError, InternalRequestError, OutputSubstitutionError, RequestError, - SelectionError, + v1, Error, InputContributionError, OutputSubstitutionError, RequestError, SelectionError, }; use crate::hpke::{decrypt_message_a, encrypt_message_b, HpkeKeyPair, HpkePublicKey}; use crate::ohttp::{ohttp_decapsulate, ohttp_encapsulate, OhttpEncapsulationError, OhttpKeys}; @@ -181,8 +181,8 @@ impl Receiver { } log::debug!("Received request with params: {:?}", params); - let inner = super::UncheckedProposal { psbt, params }; - Ok(UncheckedProposal { inner, context: self.context.clone() }) + let inner = v1::UncheckedProposal { psbt, params }; + Ok(UncheckedProposal { v1: inner, context: self.context.clone() }) } /// Build a V2 Payjoin URI from the receiver's context @@ -225,14 +225,14 @@ impl Receiver { /// call assume_interactive_receive to proceed with validation. #[derive(Debug, Clone)] pub struct UncheckedProposal { - inner: super::UncheckedProposal, + v1: v1::UncheckedProposal, context: SessionContext, } impl UncheckedProposal { /// The Sender's Original PSBT pub fn extract_tx_to_schedule_broadcast(&self) -> bitcoin::Transaction { - self.inner.extract_tx_to_schedule_broadcast() + self.v1.extract_tx_to_schedule_broadcast() } /// Call after checking that the Original PSBT can be broadcast. @@ -252,8 +252,8 @@ impl UncheckedProposal { min_fee_rate: Option, can_broadcast: impl Fn(&bitcoin::Transaction) -> Result, ) -> Result { - let inner = self.inner.check_broadcast_suitability(min_fee_rate, can_broadcast)?; - Ok(MaybeInputsOwned { inner, context: self.context }) + let inner = self.v1.check_broadcast_suitability(min_fee_rate, can_broadcast)?; + Ok(MaybeInputsOwned { v1: inner, context: self.context }) } /// Call this method if the only way to initiate a Payjoin with this receiver @@ -262,8 +262,8 @@ impl UncheckedProposal { /// So-called "non-interactive" receivers, like payment processors, that allow arbitrary requests are otherwise vulnerable to probing attacks. /// Those receivers call `extract_tx_to_check_broadcast()` and `attest_tested_and_scheduled_broadcast()` after making those checks downstream. pub fn assume_interactive_receiver(self) -> MaybeInputsOwned { - let inner = self.inner.assume_interactive_receiver(); - MaybeInputsOwned { inner, context: self.context } + let inner = self.v1.assume_interactive_receiver(); + MaybeInputsOwned { v1: inner, context: self.context } } } @@ -272,7 +272,7 @@ impl UncheckedProposal { /// Call [`check_no_receiver_owned_inputs()`](struct.UncheckedProposal.html#method.check_no_receiver_owned_inputs) to proceed. #[derive(Debug, Clone)] pub struct MaybeInputsOwned { - inner: super::MaybeInputsOwned, + v1: v1::MaybeInputsOwned, context: SessionContext, } @@ -285,8 +285,8 @@ impl MaybeInputsOwned { self, is_owned: impl Fn(&Script) -> Result, ) -> Result { - let inner = self.inner.check_inputs_not_owned(is_owned)?; - Ok(MaybeInputsSeen { inner, context: self.context }) + let inner = self.v1.check_inputs_not_owned(is_owned)?; + Ok(MaybeInputsSeen { v1: inner, context: self.context }) } } @@ -295,7 +295,7 @@ impl MaybeInputsOwned { /// Call [`check_no_inputs_seen`](struct.MaybeInputsSeen.html#method.check_no_inputs_seen_before) to proceed. #[derive(Debug, Clone)] pub struct MaybeInputsSeen { - inner: super::MaybeInputsSeen, + v1: v1::MaybeInputsSeen, context: SessionContext, } @@ -307,7 +307,7 @@ impl MaybeInputsSeen { self, is_known: impl Fn(&OutPoint) -> Result, ) -> Result { - let inner = self.inner.check_no_inputs_seen_before(is_known)?; + let inner = self.v1.check_no_inputs_seen_before(is_known)?; Ok(OutputsUnknown { inner, context: self.context }) } } @@ -318,7 +318,7 @@ impl MaybeInputsSeen { /// Identify those outputs with `identify_receiver_outputs()` to proceed #[derive(Debug, Clone)] pub struct OutputsUnknown { - inner: super::OutputsUnknown, + inner: v1::OutputsUnknown, context: SessionContext, } @@ -329,20 +329,20 @@ impl OutputsUnknown { is_receiver_output: impl Fn(&Script) -> Result, ) -> Result { let inner = self.inner.identify_receiver_outputs(is_receiver_output)?; - Ok(WantsOutputs { inner, context: self.context }) + Ok(WantsOutputs { v1: inner, context: self.context }) } } /// A checked proposal that the receiver may substitute or add outputs to #[derive(Debug, Clone)] pub struct WantsOutputs { - inner: super::WantsOutputs, + v1: v1::WantsOutputs, context: SessionContext, } impl WantsOutputs { pub fn is_output_substitution_disabled(&self) -> bool { - self.inner.is_output_substitution_disabled() + self.v1.is_output_substitution_disabled() } /// Substitute the receiver output script with the provided script. @@ -350,8 +350,8 @@ impl WantsOutputs { self, output_script: &Script, ) -> Result { - let inner = self.inner.substitute_receiver_script(output_script)?; - Ok(WantsOutputs { inner, context: self.context }) + let inner = self.v1.substitute_receiver_script(output_script)?; + Ok(WantsOutputs { v1: inner, context: self.context }) } /// Replace **all** receiver outputs with one or more provided outputs. @@ -364,22 +364,22 @@ impl WantsOutputs { replacement_outputs: Vec, drain_script: &Script, ) -> Result { - let inner = self.inner.replace_receiver_outputs(replacement_outputs, drain_script)?; - Ok(WantsOutputs { inner, context: self.context }) + let inner = self.v1.replace_receiver_outputs(replacement_outputs, drain_script)?; + Ok(WantsOutputs { v1: inner, context: self.context }) } /// Proceed to the input contribution step. /// Outputs cannot be modified after this function is called. pub fn commit_outputs(self) -> WantsInputs { - let inner = self.inner.commit_outputs(); - WantsInputs { inner, context: self.context } + let inner = self.v1.commit_outputs(); + WantsInputs { v1: inner, context: self.context } } } /// A checked proposal that the receiver may contribute inputs to to make a payjoin #[derive(Debug, Clone)] pub struct WantsInputs { - inner: super::WantsInputs, + v1: v1::WantsInputs, context: SessionContext, } @@ -399,7 +399,7 @@ impl WantsInputs { &self, candidate_inputs: impl IntoIterator, ) -> Result { - self.inner.try_preserving_privacy(candidate_inputs) + self.v1.try_preserving_privacy(candidate_inputs) } /// Add the provided list of inputs to the transaction. @@ -408,15 +408,15 @@ impl WantsInputs { self, inputs: impl IntoIterator, ) -> Result { - let inner = self.inner.contribute_inputs(inputs)?; - Ok(WantsInputs { inner, context: self.context }) + let inner = self.v1.contribute_inputs(inputs)?; + Ok(WantsInputs { v1: inner, context: self.context }) } /// Proceed to the proposal finalization step. /// Inputs cannot be modified after this function is called. pub fn commit_inputs(self) -> ProvisionalProposal { - let inner = self.inner.commit_inputs(); - ProvisionalProposal { inner, context: self.context } + let inner = self.v1.commit_inputs(); + ProvisionalProposal { v1: inner, context: self.context } } } @@ -424,7 +424,7 @@ impl WantsInputs { /// sender will accept. #[derive(Debug, Clone)] pub struct ProvisionalProposal { - inner: super::ProvisionalProposal, + v1: v1::ProvisionalProposal, context: SessionContext, } @@ -435,34 +435,34 @@ impl ProvisionalProposal { min_feerate_sat_per_vb: Option, max_feerate_sat_per_vb: FeeRate, ) -> Result { - let inner = self.inner.finalize_proposal( + let inner = self.v1.finalize_proposal( wallet_process_psbt, min_feerate_sat_per_vb, max_feerate_sat_per_vb, )?; - Ok(PayjoinProposal { inner, context: self.context }) + Ok(PayjoinProposal { v1: inner, context: self.context }) } } /// A mutable checked proposal that the receiver may contribute inputs to to make a payjoin. #[derive(Clone)] pub struct PayjoinProposal { - inner: super::PayjoinProposal, + v1: v1::PayjoinProposal, context: SessionContext, } impl PayjoinProposal { pub fn utxos_to_be_locked(&self) -> impl '_ + Iterator { - self.inner.utxos_to_be_locked() + self.v1.utxos_to_be_locked() } pub fn is_output_substitution_disabled(&self) -> bool { - self.inner.is_output_substitution_disabled() + self.v1.is_output_substitution_disabled() } - pub fn psbt(&self) -> &Psbt { self.inner.psbt() } + pub fn psbt(&self) -> &Psbt { self.v1.psbt() } - pub fn extract_v1_req(&self) -> String { self.inner.payjoin_psbt.to_string() } + pub fn extract_v1_req(&self) -> String { self.v1.psbt().to_string() } #[cfg(feature = "v2")] pub fn extract_v2_req(&mut self) -> Result<(Request, ohttp::ClientResponse), Error> { @@ -472,7 +472,7 @@ impl PayjoinProposal { if let Some(e) = &self.context.e { // Prepare v2 payload - let payjoin_bytes = self.inner.payjoin_psbt.serialize(); + let payjoin_bytes = self.v1.psbt().serialize(); let sender_subdir = subdir_path_from_pubkey(e); target_resource = self .context diff --git a/payjoin/tests/integration.rs b/payjoin/tests/integration.rs index 419f5160..cb71aea4 100644 --- a/payjoin/tests/integration.rs +++ b/payjoin/tests/integration.rs @@ -12,7 +12,8 @@ mod integration { use bitcoind::bitcoincore_rpc::{self, RpcApi}; use log::{log_enabled, Level}; use once_cell::sync::{Lazy, OnceCell}; - use payjoin::receive::{build_v1_pj_uri, InputPair}; + use payjoin::receive::v1::build_v1_pj_uri; + use payjoin::receive::InputPair; use payjoin::{PjUri, Request, Uri}; use tracing_subscriber::{EnvFilter, FmtSubscriber}; use url::Url; @@ -1249,14 +1250,14 @@ mod integration { // 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::Headers, + headers: impl payjoin::receive::v1::Headers, receiver: &bitcoincore_rpc::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::UncheckedProposal::from_request( + let proposal = payjoin::receive::v1::UncheckedProposal::from_request( req.body.as_slice(), req.url.query().unwrap_or(""), headers, @@ -1270,12 +1271,12 @@ mod integration { } fn handle_proposal( - proposal: payjoin::receive::UncheckedProposal, + proposal: payjoin::receive::v1::UncheckedProposal, receiver: &bitcoincore_rpc::Client, custom_outputs: Option>, drain_script: Option<&bitcoin::Script>, custom_inputs: Option>, - ) -> Result { + ) -> Result { // 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(); @@ -1407,7 +1408,7 @@ mod integration { struct HeaderMock(HashMap); - impl payjoin::receive::Headers for HeaderMock { + impl payjoin::receive::v1::Headers for HeaderMock { fn get_header(&self, key: &str) -> Option<&str> { self.0.get(key).map(|e| e.as_str()) } } From dd0ed07cc35c0a4c556bfb9850120f62101f3135 Mon Sep 17 00:00:00 2001 From: DanGould Date: Mon, 6 Jan 2025 23:32:21 -0500 Subject: [PATCH 3/5] Pass `supported_versions` from calling module This finally allows v1, v2 features to be additive. However, it does complicate the UnknownVersion variant (used only by v1 as far as I can tell). I'm inclined to leave this as an open issue until we separate the receive::{v1,v2} errors from each other. --- payjoin/src/receive/error.rs | 5 ++--- payjoin/src/receive/optional_parameters.rs | 19 ++++++++----------- payjoin/src/receive/v1.rs | 5 ++++- payjoin/src/receive/v2/mod.rs | 9 +++++++-- 4 files changed, 21 insertions(+), 17 deletions(-) diff --git a/payjoin/src/receive/error.rs b/payjoin/src/receive/error.rs index 4783bbdb..d6271c7f 100644 --- a/payjoin/src/receive/error.rs +++ b/payjoin/src/receive/error.rs @@ -123,7 +123,7 @@ impl fmt::Display for RequestError { format!("Content length too large: {}.", length), ), InternalRequestError::SenderParams(e) => match e { - super::optional_parameters::Error::UnknownVersion => { + super::optional_parameters::Error::UnknownVersion { supported_versions } => { write!( f, r#"{{ @@ -131,8 +131,7 @@ impl fmt::Display for RequestError { "supported": "{}", "message": "This version of payjoin is not supported." }}"#, - serde_json::to_string(&super::optional_parameters::SUPPORTED_VERSIONS) - .map_err(|_| fmt::Error)? + serde_json::to_string(supported_versions).map_err(|_| fmt::Error)? ) } _ => write_error(f, "sender-params-error", e), diff --git a/payjoin/src/receive/optional_parameters.rs b/payjoin/src/receive/optional_parameters.rs index 2db96f29..dabcd2ee 100644 --- a/payjoin/src/receive/optional_parameters.rs +++ b/payjoin/src/receive/optional_parameters.rs @@ -4,11 +4,6 @@ use std::fmt; use bitcoin::FeeRate; use log::warn; -#[cfg(feature = "v2")] -pub(crate) const SUPPORTED_VERSIONS: [usize; 2] = [1, 2]; -#[cfg(not(feature = "v2"))] -pub(crate) const SUPPORTED_VERSIONS: [usize; 1] = [1]; - #[derive(Debug, Clone)] pub(crate) struct Params { // version @@ -33,8 +28,10 @@ impl Default for Params { } impl Params { - #[cfg(feature = "receive")] - pub fn from_query_pairs(pairs: I) -> Result + pub fn from_query_pairs( + pairs: I, + supported_versions: &'static [usize], + ) -> Result where I: Iterator, K: Borrow + Into, @@ -49,8 +46,8 @@ impl Params { match (key.borrow(), v.borrow()) { ("v", version) => params.v = match version.parse::() { - Ok(version) if SUPPORTED_VERSIONS.contains(&version) => version, - _ => return Err(Error::UnknownVersion), + Ok(version) if supported_versions.contains(&version) => version, + _ => return Err(Error::UnknownVersion { supported_versions }), }, ("additionalfeeoutputindex", index) => additional_fee_output_index = match index.parse::() { @@ -107,14 +104,14 @@ impl Params { #[derive(Debug)] pub(crate) enum Error { - UnknownVersion, + UnknownVersion { supported_versions: &'static [usize] }, FeeRate, } impl fmt::Display for Error { fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { match self { - Error::UnknownVersion => write!(f, "unknown version"), + Error::UnknownVersion { .. } => write!(f, "unknown version"), Error::FeeRate => write!(f, "could not parse feerate"), } } diff --git a/payjoin/src/receive/v1.rs b/payjoin/src/receive/v1.rs index 5e9afa60..ec16412f 100644 --- a/payjoin/src/receive/v1.rs +++ b/payjoin/src/receive/v1.rs @@ -43,6 +43,8 @@ use super::{ }; use crate::psbt::PsbtExt; +const SUPPORTED_VERSIONS: &[usize] = &[1]; + pub trait Headers { fn get_header(&self, key: &str) -> Option<&str>; } @@ -104,7 +106,8 @@ impl UncheckedProposal { log::debug!("Received original psbt: {:?}", psbt); let pairs = url::form_urlencoded::parse(query.as_bytes()); - let params = Params::from_query_pairs(pairs).map_err(InternalRequestError::SenderParams)?; + let params = Params::from_query_pairs(pairs, SUPPORTED_VERSIONS) + .map_err(InternalRequestError::SenderParams)?; log::debug!("Received request with params: {:?}", params); // TODO check that params are valid for the request's Original PSBT diff --git a/payjoin/src/receive/v2/mod.rs b/payjoin/src/receive/v2/mod.rs index f0a51fc8..36ea5a58 100644 --- a/payjoin/src/receive/v2/mod.rs +++ b/payjoin/src/receive/v2/mod.rs @@ -23,6 +23,8 @@ use crate::Request; pub(crate) mod error; +const SUPPORTED_VERSIONS: &[usize] = &[1, 2]; + static TWENTY_FOUR_HOURS_DEFAULT_EXPIRY: Duration = Duration::from_secs(60 * 60 * 24); #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] @@ -165,8 +167,11 @@ impl Receiver { let unchecked_psbt = Psbt::from_str(base64).map_err(InternalRequestError::ParsePsbt)?; let psbt = unchecked_psbt.validate().map_err(InternalRequestError::InconsistentPsbt)?; log::debug!("Received original psbt: {:?}", psbt); - let mut params = Params::from_query_pairs(url::form_urlencoded::parse(query.as_bytes())) - .map_err(InternalRequestError::SenderParams)?; + let mut params = Params::from_query_pairs( + url::form_urlencoded::parse(query.as_bytes()), + SUPPORTED_VERSIONS, + ) + .map_err(InternalRequestError::SenderParams)?; // Output substitution must be disabled for V1 sessions in V2 contexts. // From 149f40ddb0ba60c0245b6f6eceaa24179438542b Mon Sep 17 00:00:00 2001 From: DanGould Date: Mon, 6 Jan 2025 17:45:44 -0500 Subject: [PATCH 4/5] Remove not(feature ="v2") in payjoin crate This allows v1, v2 features to be additive and used side by side. --- payjoin/contrib/test.sh | 3 +-- payjoin/tests/integration.rs | 2 -- 2 files changed, 1 insertion(+), 4 deletions(-) diff --git a/payjoin/contrib/test.sh b/payjoin/contrib/test.sh index df2a3028..d0e949c3 100755 --- a/payjoin/contrib/test.sh +++ b/payjoin/contrib/test.sh @@ -2,5 +2,4 @@ set -e cargo test --locked --package payjoin --verbose --all-features --lib -cargo test --locked --package payjoin --verbose --features=send,receive --test integration -cargo test --locked --package payjoin --verbose --no-default-features --features=send,receive,_danger-local-https,v2,io --test integration +cargo test --locked --package payjoin --verbose --all-features --test integration diff --git a/payjoin/tests/integration.rs b/payjoin/tests/integration.rs index cb71aea4..e51620ed 100644 --- a/payjoin/tests/integration.rs +++ b/payjoin/tests/integration.rs @@ -24,7 +24,6 @@ mod integration { static EXAMPLE_URL: Lazy = Lazy::new(|| Url::parse("https://example.com").expect("Invalid Url")); - #[cfg(not(feature = "v2"))] mod v1 { use log::debug; use payjoin::send::v1::SenderBuilder; @@ -996,7 +995,6 @@ mod integration { } } - #[cfg(not(feature = "v2"))] mod batching { use payjoin::send::v1::SenderBuilder; use payjoin::UriExt; From 5c78a159454848fcef3cee3e582dd687e0dfa53d Mon Sep 17 00:00:00 2001 From: DanGould Date: Wed, 8 Jan 2025 12:34:08 -0500 Subject: [PATCH 5/5] Remove unnecessary, misnamed fn extract_v1_req This function does NOT extract the request nor the complete body and is better represented by an direct call to the internal behavior. --- payjoin/src/receive/v2/mod.rs | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/payjoin/src/receive/v2/mod.rs b/payjoin/src/receive/v2/mod.rs index 36ea5a58..b5b2f160 100644 --- a/payjoin/src/receive/v2/mod.rs +++ b/payjoin/src/receive/v2/mod.rs @@ -467,8 +467,6 @@ impl PayjoinProposal { pub fn psbt(&self) -> &Psbt { self.v1.psbt() } - pub fn extract_v1_req(&self) -> String { self.v1.psbt().to_string() } - #[cfg(feature = "v2")] pub fn extract_v2_req(&mut self) -> Result<(Request, ohttp::ClientResponse), Error> { let target_resource: Url; @@ -488,7 +486,7 @@ impl PayjoinProposal { method = "POST"; } else { // Prepare v2 wrapped and backwards-compatible v1 payload - body = self.extract_v1_req().as_bytes().to_vec(); + body = self.v1.psbt().to_string().as_bytes().to_vec(); let receiver_subdir = subdir_path_from_pubkey(self.context.s.public_key()); target_resource = self .context