diff --git a/payjoin-cli/src/app/config.rs b/payjoin-cli/src/app/config.rs index d89ddf6c..5c275e1a 100644 --- a/payjoin-cli/src/app/config.rs +++ b/payjoin-cli/src/app/config.rs @@ -15,6 +15,8 @@ pub struct AppConfig { pub bitcoind_rpcuser: String, pub bitcoind_rpcpassword: String, pub db_path: PathBuf, + // receive-only + pub max_fee_rate: Option, // v2 only #[cfg(feature = "v2")] @@ -113,7 +115,14 @@ impl AppConfig { )? }; - builder + let max_fee_rate = matches + .get_one::("max_fee_rate") + .map(|max_fee_rate| max_fee_rate.parse::()) + .transpose() + .map_err(|_| { + ConfigError::Message("\"max_fee_rate\" must be a valid amount".to_string()) + })?; + builder.set_override_option("max_fee_rate", max_fee_rate)? } #[cfg(feature = "v2")] Some(("resume", _)) => builder, diff --git a/payjoin-cli/src/app/mod.rs b/payjoin-cli/src/app/mod.rs index 811f3b58..73cb0dd2 100644 --- a/payjoin-cli/src/app/mod.rs +++ b/payjoin-cli/src/app/mod.rs @@ -94,38 +94,6 @@ pub trait App { } } -fn try_contributing_inputs( - payjoin: &mut payjoin::receive::ProvisionalProposal, - bitcoind: &bitcoincore_rpc::Client, -) -> Result<()> { - use bitcoin::OutPoint; - - let available_inputs = bitcoind - .list_unspent(None, None, None, None, None) - .context("Failed to list unspent from bitcoind")?; - let candidate_inputs: HashMap = available_inputs - .iter() - .map(|i| (i.amount, OutPoint { txid: i.txid, vout: i.vout })) - .collect(); - - let selected_outpoint = payjoin.try_preserving_privacy(candidate_inputs).expect("gg"); - let selected_utxo = available_inputs - .iter() - .find(|i| i.txid == selected_outpoint.txid && i.vout == selected_outpoint.vout) - .context("This shouldn't happen. Failed to retrieve the privacy preserving utxo from those we provided to the seclector.")?; - log::debug!("selected utxo: {:#?}", selected_utxo); - - // calculate receiver payjoin outputs given receiver payjoin inputs and original_psbt, - let txo_to_contribute = bitcoin::TxOut { - value: selected_utxo.amount, - script_pubkey: selected_utxo.script_pub_key.clone(), - }; - let outpoint_to_contribute = - bitcoin::OutPoint { txid: selected_utxo.txid, vout: selected_utxo.vout }; - payjoin.contribute_witness_input(txo_to_contribute, outpoint_to_contribute); - Ok(()) -} - #[cfg(feature = "danger-local-https")] fn http_agent() -> Result { Ok(http_agent_builder()?.build()?) } diff --git a/payjoin-cli/src/app/v1.rs b/payjoin-cli/src/app/v1.rs index 53c9d539..c7a20ca1 100644 --- a/payjoin-cli/src/app/v1.rs +++ b/payjoin-cli/src/app/v1.rs @@ -14,14 +14,14 @@ use hyper::service::service_fn; use hyper::{Method, Request, Response, StatusCode}; use hyper_util::rt::TokioIo; use payjoin::bitcoin::psbt::Psbt; -use payjoin::bitcoin::{self}; +use payjoin::bitcoin::{self, FeeRate}; use payjoin::receive::{PayjoinProposal, UncheckedProposal}; use payjoin::{Error, PjUriBuilder, Uri, UriExt}; use tokio::net::TcpListener; use super::config::AppConfig; use super::App as AppTrait; -use crate::app::{http_agent, try_contributing_inputs}; +use crate::app::http_agent; use crate::db::Database; #[cfg(feature = "danger-local-https")] pub const LOCAL_CERT_FILE: &str = "localhost.der"; @@ -329,7 +329,7 @@ impl App { })?; log::trace!("check4"); - let mut provisional_payjoin = payjoin.identify_receiver_outputs(|output_script| { + let payjoin = payjoin.identify_receiver_outputs(|output_script| { if let Ok(address) = bitcoin::Address::from_script(output_script, network) { bitcoind .get_address_info(&address) @@ -340,19 +340,23 @@ impl App { } })?; - _ = try_contributing_inputs(&mut provisional_payjoin, &bitcoind) - .map_err(|e| log::warn!("Failed to contribute inputs: {}", e)); - - _ = provisional_payjoin - .try_substitute_receiver_output(|| { - Ok(bitcoind + let payjoin = payjoin + .substitute_receiver_script( + &bitcoind .get_new_address(None, None) .map_err(|e| Error::Server(e.into()))? .require_network(network) .map_err(|e| Error::Server(e.into()))? - .script_pubkey()) - }) - .map_err(|e| log::warn!("Failed to substitute output: {}", e)); + .script_pubkey(), + ) + .map_err(|e| Error::Server(e.into()))? + .commit_outputs(); + + let provisional_payjoin = try_contributing_inputs(payjoin.clone(), &bitcoind) + .unwrap_or_else(|e| { + log::warn!("Failed to contribute inputs: {}", e); + payjoin.commit_inputs() + }); let payjoin_proposal = provisional_payjoin.finalize_proposal( |psbt: &Psbt| { @@ -362,11 +366,45 @@ impl App { .map_err(|e| Error::Server(e.into()))? }, Some(bitcoin::FeeRate::MIN), + self.config.max_fee_rate.map_or(Ok(FeeRate::ZERO), |fee_rate| { + FeeRate::from_sat_per_vb(fee_rate).ok_or(Error::Server("Invalid fee rate".into())) + })?, )?; Ok(payjoin_proposal) } } +fn try_contributing_inputs( + payjoin: payjoin::receive::WantsInputs, + bitcoind: &bitcoincore_rpc::Client, +) -> Result { + use bitcoin::OutPoint; + + let available_inputs = bitcoind + .list_unspent(None, None, None, None, None) + .context("Failed to list unspent from bitcoind")?; + let candidate_inputs: HashMap = available_inputs + .iter() + .map(|i| (i.amount, OutPoint { txid: i.txid, vout: i.vout })) + .collect(); + + let selected_outpoint = payjoin.try_preserving_privacy(candidate_inputs).expect("gg"); + let selected_utxo = available_inputs + .iter() + .find(|i| i.txid == selected_outpoint.txid && i.vout == selected_outpoint.vout) + .context("This shouldn't happen. Failed to retrieve the privacy preserving utxo from those we provided to the seclector.")?; + log::debug!("selected utxo: {:#?}", selected_utxo); + let txo_to_contribute = bitcoin::TxOut { + value: selected_utxo.amount, + script_pubkey: selected_utxo.script_pub_key.clone(), + }; + + Ok(payjoin + .contribute_witness_inputs(vec![(selected_outpoint, txo_to_contribute)]) + .expect("This shouldn't happen. Failed to contribute inputs.") + .commit_inputs()) +} + fn full>(chunk: T) -> BoxBody { Full::new(chunk.into()).map_err(|never| match never {}).boxed() } diff --git a/payjoin-cli/src/app/v2.rs b/payjoin-cli/src/app/v2.rs index 2aba3f8f..c2a20be9 100644 --- a/payjoin-cli/src/app/v2.rs +++ b/payjoin-cli/src/app/v2.rs @@ -1,3 +1,4 @@ +use std::collections::HashMap; use std::str::FromStr; use std::sync::Arc; @@ -5,7 +6,7 @@ use anyhow::{anyhow, Context, Result}; use bitcoincore_rpc::RpcApi; use payjoin::bitcoin::consensus::encode::serialize_hex; use payjoin::bitcoin::psbt::Psbt; -use payjoin::bitcoin::Amount; +use payjoin::bitcoin::{Amount, FeeRate}; use payjoin::receive::v2::ActiveSession; use payjoin::send::RequestContext; use payjoin::{bitcoin, Error, Uri}; @@ -272,8 +273,6 @@ impl App { &self, proposal: payjoin::receive::v2::UncheckedProposal, ) -> Result { - use crate::app::try_contributing_inputs; - let bitcoind = self.bitcoind().map_err(|e| Error::Server(e.into()))?; // in a payment processor where the sender could go offline, this is where you schedule to broadcast the original_tx @@ -317,19 +316,24 @@ impl App { })?; log::trace!("check4"); - let mut provisional_payjoin = payjoin.identify_receiver_outputs(|output_script| { - if let Ok(address) = bitcoin::Address::from_script(output_script, network) { - bitcoind - .get_address_info(&address) - .map(|info| info.is_mine.unwrap_or(false)) - .map_err(|e| Error::Server(e.into())) - } else { - Ok(false) - } - })?; + let payjoin = payjoin + .identify_receiver_outputs(|output_script| { + if let Ok(address) = bitcoin::Address::from_script(output_script, network) { + bitcoind + .get_address_info(&address) + .map(|info| info.is_mine.unwrap_or(false)) + .map_err(|e| Error::Server(e.into())) + } else { + Ok(false) + } + })? + .commit_outputs(); - _ = try_contributing_inputs(&mut provisional_payjoin.inner, &bitcoind) - .map_err(|e| log::warn!("Failed to contribute inputs: {}", e)); + let provisional_payjoin = try_contributing_inputs(payjoin.clone(), &bitcoind) + .unwrap_or_else(|e| { + log::warn!("Failed to contribute inputs: {}", e); + payjoin.commit_inputs() + }); let payjoin_proposal = provisional_payjoin.finalize_proposal( |psbt: &Psbt| { @@ -339,6 +343,9 @@ impl App { .map_err(|e| Error::Server(e.into()))? }, Some(bitcoin::FeeRate::MIN), + self.config.max_fee_rate.map_or(Ok(FeeRate::ZERO), |fee_rate| { + FeeRate::from_sat_per_vb(fee_rate).ok_or(Error::Server("Invalid fee rate".into())) + })?, )?; let payjoin_proposal_psbt = payjoin_proposal.psbt(); log::debug!("Receiver's Payjoin proposal PSBT Rsponse: {:#?}", payjoin_proposal_psbt); @@ -346,6 +353,37 @@ impl App { } } +fn try_contributing_inputs( + payjoin: payjoin::receive::v2::WantsInputs, + bitcoind: &bitcoincore_rpc::Client, +) -> Result { + use bitcoin::OutPoint; + + let available_inputs = bitcoind + .list_unspent(None, None, None, None, None) + .context("Failed to list unspent from bitcoind")?; + let candidate_inputs: HashMap = available_inputs + .iter() + .map(|i| (i.amount, OutPoint { txid: i.txid, vout: i.vout })) + .collect(); + + let selected_outpoint = payjoin.try_preserving_privacy(candidate_inputs).expect("gg"); + let selected_utxo = available_inputs + .iter() + .find(|i| i.txid == selected_outpoint.txid && i.vout == selected_outpoint.vout) + .context("This shouldn't happen. Failed to retrieve the privacy preserving utxo from those we provided to the seclector.")?; + log::debug!("selected utxo: {:#?}", selected_utxo); + let txo_to_contribute = bitcoin::TxOut { + value: selected_utxo.amount, + script_pubkey: selected_utxo.script_pub_key.clone(), + }; + + Ok(payjoin + .contribute_witness_inputs(vec![(selected_outpoint, txo_to_contribute)]) + .expect("This shouldn't happen. Failed to contribute inputs.") + .commit_inputs()) +} + async fn unwrap_ohttp_keys_or_else_fetch(config: &AppConfig) -> Result { if let Some(keys) = config.ohttp_keys.clone() { println!("Using OHTTP Keys from config"); diff --git a/payjoin-cli/src/main.rs b/payjoin-cli/src/main.rs index deb50d7a..f14c9256 100644 --- a/payjoin-cli/src/main.rs +++ b/payjoin-cli/src/main.rs @@ -111,6 +111,12 @@ fn cli() -> ArgMatches { let mut cmd = cmd.subcommand(Command::new("resume")); // Conditional arguments based on features for the receive subcommand + receive_cmd = receive_cmd.arg( + Arg::new("max_fee_rate") + .long("max-fee-rate") + .num_args(1) + .help("The maximum effective fee rate the receiver is willing to pay (in sat/vB)"), + ); #[cfg(not(feature = "v2"))] { receive_cmd = receive_cmd.arg( diff --git a/payjoin/src/receive/error.rs b/payjoin/src/receive/error.rs index 6c7d21ea..2056bcf8 100644 --- a/payjoin/src/receive/error.rs +++ b/payjoin/src/receive/error.rs @@ -90,6 +90,8 @@ pub(crate) enum InternalRequestError { /// /// Second argument is the minimum fee rate optionaly set by the receiver. PsbtBelowFeeRate(bitcoin::FeeRate, bitcoin::FeeRate), + /// Effective receiver feerate exceeds maximum allowed feerate + FeeTooHigh(bitcoin::FeeRate, bitcoin::FeeRate), } impl From for RequestError { @@ -172,6 +174,14 @@ impl fmt::Display for RequestError { original_psbt_fee_rate, receiver_min_fee_rate ), ), + InternalRequestError::FeeTooHigh(proposed_feerate, max_feerate) => write_error( + f, + "original-psbt-rejected", + &format!( + "Effective receiver feerate exceeds maximum allowed feerate: {} > {}", + proposed_feerate, max_feerate + ), + ), } } } @@ -196,6 +206,51 @@ impl std::error::Error for RequestError { } } +/// Error that may occur when output substitution fails. +/// +/// This is currently opaque type because we aren't sure which variants will stay. +/// You can only display it. +#[derive(Debug)] +pub struct OutputSubstitutionError(InternalOutputSubstitutionError); + +#[derive(Debug)] +pub(crate) enum InternalOutputSubstitutionError { + /// Output substitution is disabled + OutputSubstitutionDisabled(&'static str), + /// Current output substitution implementation doesn't support reducing the number of outputs + NotEnoughOutputs, + /// The provided drain script could not be identified in the provided replacement outputs + InvalidDrainScript, +} + +impl fmt::Display for OutputSubstitutionError { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + match &self.0 { + InternalOutputSubstitutionError::OutputSubstitutionDisabled(reason) => write!(f, "{}", &format!("Output substitution is disabled: {}", reason)), + InternalOutputSubstitutionError::NotEnoughOutputs => write!( + f, + "Current output substitution implementation doesn't support reducing the number of outputs" + ), + InternalOutputSubstitutionError::InvalidDrainScript => + write!(f, "The provided drain script could not be identified in the provided replacement outputs"), + } + } +} + +impl From for OutputSubstitutionError { + fn from(value: InternalOutputSubstitutionError) -> Self { OutputSubstitutionError(value) } +} + +impl std::error::Error for OutputSubstitutionError { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + match &self.0 { + InternalOutputSubstitutionError::OutputSubstitutionDisabled(_) => None, + InternalOutputSubstitutionError::NotEnoughOutputs => None, + InternalOutputSubstitutionError::InvalidDrainScript => None, + } + } +} + /// Error that may occur when coin selection fails. /// /// This is currently opaque type because we aren't sure which variants will stay. @@ -230,3 +285,29 @@ impl fmt::Display for SelectionError { impl From for SelectionError { fn from(value: InternalSelectionError) -> Self { SelectionError(value) } } + +/// Error that may occur when input contribution fails. +/// +/// This is currently opaque type because we aren't sure which variants will stay. +/// You can only display it. +#[derive(Debug)] +pub struct InputContributionError(InternalInputContributionError); + +#[derive(Debug)] +pub(crate) enum InternalInputContributionError { + /// Total input value is not enough to cover additional output value + ValueTooLow, +} + +impl fmt::Display for InputContributionError { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + match &self.0 { + InternalInputContributionError::ValueTooLow => + write!(f, "Total input value is not enough to cover additional output value"), + } + } +} + +impl From for InputContributionError { + fn from(value: InternalInputContributionError) -> Self { InputContributionError(value) } +} diff --git a/payjoin/src/receive/mod.rs b/payjoin/src/receive/mod.rs index 78bafaf3..44ce7a31 100644 --- a/payjoin/src/receive/mod.rs +++ b/payjoin/src/receive/mod.rs @@ -30,7 +30,7 @@ use std::collections::HashMap; use bitcoin::base64::prelude::BASE64_STANDARD; use bitcoin::base64::Engine; use bitcoin::psbt::Psbt; -use bitcoin::{Amount, FeeRate, OutPoint, Script, TxOut}; +use bitcoin::{Amount, FeeRate, OutPoint, Script, TxOut, Weight}; mod error; mod optional_parameters; @@ -39,8 +39,11 @@ pub mod v2; use bitcoin::secp256k1::rand::seq::SliceRandom; use bitcoin::secp256k1::rand::{self, Rng}; -pub use error::{Error, RequestError, SelectionError}; -use error::{InternalRequestError, InternalSelectionError}; +pub use error::{Error, OutputSubstitutionError, RequestError, SelectionError}; +use error::{ + InputContributionError, InternalInputContributionError, InternalOutputSubstitutionError, + InternalRequestError, InternalSelectionError, +}; use optional_parameters::Params; use crate::input_type::InputType; @@ -307,7 +310,7 @@ impl OutputsUnknown { pub fn identify_receiver_outputs( self, is_receiver_output: impl Fn(&Script) -> Result, - ) -> Result { + ) -> Result { let owned_vouts: Vec = self .psbt .unsigned_tx @@ -325,25 +328,176 @@ impl OutputsUnknown { return Err(Error::BadRequest(InternalRequestError::MissingPayment.into())); } - Ok(ProvisionalProposal { + 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: self.params, + params, + change_vout: owned_vouts[0], owned_vouts, }) } } -/// A mutable checked proposal that the receiver may contribute inputs to to make a payjoin. +/// A checked proposal that the receiver may substitute or add outputs to #[derive(Debug, Clone)] -pub struct ProvisionalProposal { +pub struct WantsOutputs { original_psbt: Psbt, payjoin_psbt: Psbt, params: Params, + change_vout: usize, owned_vouts: Vec, } -impl ProvisionalProposal { +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 Vec, + 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 +#[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. /// @@ -357,13 +511,13 @@ impl ProvisionalProposal { candidate_inputs: HashMap, ) -> Result { if candidate_inputs.is_empty() { - return Err(SelectionError::from(InternalSelectionError::Empty)); + 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(SelectionError::from(InternalSelectionError::TooManyOutputs)); + return Err(InternalSelectionError::TooManyOutputs.into()); } if self.payjoin_psbt.outputs.len() == 2 { @@ -376,8 +530,8 @@ impl ProvisionalProposal { /// 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 - // https://eprint.iacr.org/2022/589.pdf + /// if min(in) > min(out) then UIH1 else UIH2 + /// https://eprint.iacr.org/2022/589.pdf fn avoid_uih( &self, candidate_inputs: HashMap, @@ -389,16 +543,16 @@ impl ProvisionalProposal { .iter() .map(|output| output.value) .min() - .unwrap_or_else(|| Amount::MAX_MONEY); + .unwrap_or(Amount::MAX_MONEY); let min_original_in_sats = self .payjoin_psbt .input_pairs() .filter_map(|input| input.previous_txout().ok().map(|txo| txo.value)) .min() - .unwrap_or_else(|| Amount::MAX_MONEY); + .unwrap_or(Amount::MAX_MONEY); - let prior_payment_sats = self.payjoin_psbt.unsigned_tx.output[self.owned_vouts[0]].value; + let prior_payment_sats = self.payjoin_psbt.unsigned_tx.output[self.change_vout].value; for candidate in candidate_inputs { let candidate_sats = candidate.0; @@ -413,21 +567,23 @@ impl ProvisionalProposal { } // No suitable privacy preserving selection found - Err(SelectionError::from(InternalSelectionError::NotFound)) + Err(InternalSelectionError::NotFound.into()) } fn select_first_candidate( &self, candidate_inputs: HashMap, ) -> Result { - candidate_inputs - .values() - .next() - .cloned() - .ok_or_else(|| SelectionError::from(InternalSelectionError::NotFound)) + candidate_inputs.values().next().cloned().ok_or(InternalSelectionError::NotFound.into()) } - pub fn contribute_witness_input(&mut self, txo: TxOut, outpoint: OutPoint) { + /// 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_witness_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 .payjoin_psbt @@ -437,92 +593,199 @@ impl ProvisionalProposal { .map(|input| input.sequence) .unwrap_or_default(); - // Add the value of new receiver input to receiver output - let txo_value = txo.value; - let vout_to_augment = - self.owned_vouts.choose(&mut rand::thread_rng()).expect("owned_vouts is empty"); - self.payjoin_psbt.unsigned_tx.output[*vout_to_augment].value += txo_value; - - // Insert contribution at random index for privacy + // Insert contributions at random indices for privacy let mut rng = rand::thread_rng(); - let index = rng.gen_range(0..=self.payjoin_psbt.unsigned_tx.input.len()); - self.payjoin_psbt - .inputs - .insert(index, bitcoin::psbt::Input { witness_utxo: Some(txo), ..Default::default() }); - self.payjoin_psbt.unsigned_tx.input.insert( - index, - bitcoin::TxIn { - previous_output: outpoint, - sequence: original_sequence, - ..Default::default() - }, - ); + let mut receiver_input_amount = Amount::ZERO; + for (outpoint, txo) in inputs.into_iter() { + receiver_input_amount += txo.value; + let index = rng.gen_range(0..=self.payjoin_psbt.unsigned_tx.input.len()); + payjoin_psbt.inputs.insert( + index, + bitcoin::psbt::Input { witness_utxo: Some(txo), ..Default::default() }, + ); + payjoin_psbt.unsigned_tx.input.insert( + index, + bitcoin::TxIn { + previous_output: outpoint, + sequence: original_sequence, + ..Default::default() + }, + ); + } + + // 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, + }) } - pub fn is_output_substitution_disabled(&self) -> bool { - self.params.disable_output_substitution + // 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) } - /// If output substitution is enabled, replace the receiver's output script with a new one. - pub fn try_substitute_receiver_output( - &mut self, - generate_script: impl Fn() -> Result, - ) -> Result<(), Error> { - if self.params.disable_output_substitution { - return Err(Error::Server("Output substitution is disabled.".into())); + /// 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, } - let substitute_script = generate_script()?; - self.payjoin_psbt.unsigned_tx.output[self.owned_vouts[0]].script_pubkey = substitute_script; - Ok(()) } +} +/// A checked proposal that the receiver may sign and finalize to make a proposal PSBT that the +/// sender will accept. +#[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 /// - /// WARNING: DO NOT ALTER INPUTS OR OUTPUTS AFTER THIS STEP - fn apply_fee(&mut self, min_feerate: Option) -> Result<&Psbt, RequestError> { + /// 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); - // this error should never happen. We check for at least one input in the constructor - let input_pair = self - .payjoin_psbt - .input_pairs() - .next() - .ok_or(InternalRequestError::OriginalPsbtNotBroadcastable)?; - let txo = input_pair.previous_txout().map_err(InternalRequestError::PrevTxOut)?; - let input_type = InputType::from_spent_input(txo, &self.payjoin_psbt.inputs[0]) - .map_err(InternalRequestError::InputType)?; - let contribution_weight = input_type.expected_input_weight(); - log::trace!("contribution_weight: {}", contribution_weight); - let mut additional_fee = contribution_weight * min_feerate; - let max_additional_fee_contribution = - self.params.additional_fee_contribution.unwrap_or_default().0; - if additional_fee >= max_additional_fee_contribution { - // Cap fee at the sender's contribution to simplify this method - additional_fee = max_additional_fee_contribution; - } + // 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); - if additional_fee > bitcoin::Amount::ZERO { + 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((_, additional_fee_output_index)) = self.params.additional_fee_contribution + if let Some((max_additional_fee_contribution, additional_fee_output_index)) = + self.params.additional_fee_contribution { - if !self.owned_vouts.contains(&additional_fee_output_index) { - // remove additional miner fee from the sender's specified output - self.payjoin_psbt.unsigned_tx.output[additional_fee_output_index].value -= - additional_fee; - } + // 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 { + // This error should never happen. We check for at least one input in the constructor + let input_pair = self + .payjoin_psbt + .input_pairs() + .next() + .ok_or(InternalRequestError::OriginalPsbtNotBroadcastable)?; + // Calculate the additional fee contribution + let txo = input_pair.previous_txout().map_err(InternalRequestError::PrevTxOut)?; + let input_type = InputType::from_spent_input(txo, &self.payjoin_psbt.inputs[0]) + .map_err(InternalRequestError::InputType)?; + let input_count = self.payjoin_psbt.inputs.len() - self.original_psbt.inputs.len(); + log::trace!("input_count : {}", input_count); + let weight_per_input = input_type.expected_input_weight(); + log::trace!("weight_per_input : {}", weight_per_input); + let contribution_weight = weight_per_input * input_count as u64; + log::trace!("contribution_weight: {}", contribution_weight); + Ok(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 + } + /// 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, @@ -552,11 +815,7 @@ impl ProvisionalProposal { self.payjoin_psbt.inputs[i].tap_key_sig = None; } - Ok(PayjoinProposal { - payjoin_psbt: self.payjoin_psbt, - owned_vouts: self.owned_vouts, - params: self.params, - }) + Ok(PayjoinProposal { payjoin_psbt: self.payjoin_psbt, params: self.params }) } fn sender_input_indexes(&self) -> Vec { @@ -583,6 +842,7 @@ impl ProvisionalProposal { mut self, wallet_process_psbt: impl Fn(&Psbt) -> Result, min_feerate_sat_per_vb: Option, + max_feerate_sat_per_vb: FeeRate, ) -> Result { for i in self.sender_input_indexes() { log::trace!("Clearing sender script signatures for input {}", i); @@ -590,7 +850,7 @@ impl ProvisionalProposal { self.payjoin_psbt.inputs[i].final_script_witness = None; self.payjoin_psbt.inputs[i].tap_key_sig = None; } - let psbt = self.apply_fee(min_feerate_sat_per_vb)?; + let psbt = self.apply_fee(min_feerate_sat_per_vb, max_feerate_sat_per_vb)?; let psbt = wallet_process_psbt(psbt)?; let payjoin_proposal = self.prepare_psbt(psbt)?; Ok(payjoin_proposal) @@ -602,7 +862,6 @@ impl ProvisionalProposal { pub struct PayjoinProposal { payjoin_psbt: Psbt, params: Params, - owned_vouts: Vec, } impl PayjoinProposal { @@ -614,13 +873,18 @@ impl PayjoinProposal { self.params.disable_output_substitution } - pub fn owned_vouts(&self) -> &Vec { &self.owned_vouts } - pub fn psbt(&self) -> &Psbt { &self.payjoin_psbt } } #[cfg(test)] mod test { + use std::str::FromStr; + + use bitcoin::hashes::Hash; + use bitcoin::{Address, Network, ScriptBuf}; + use rand::rngs::StdRng; + use rand::SeedableRng; + use super::*; struct MockHeaders { @@ -666,10 +930,6 @@ mod test { #[test] fn unchecked_proposal_unlocks_after_checks() { - use std::str::FromStr; - - use bitcoin::{Address, Network}; - let proposal = proposal_from_test_vector().unwrap(); assert_eq!(proposal.psbt_fee_rate().unwrap().to_sat_per_vb_floor(), 2); let mut payjoin = proposal @@ -688,9 +948,86 @@ mod test { .require_network(network) .unwrap()) }) - .expect("Receiver output should be identified"); - let payjoin = payjoin.apply_fee(None); + .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 input: (OutPoint, TxOut) = ( + OutPoint { + txid: "833b085de288cda6ff614c6e8655f61e7ae4f84604a2751998dc25a0d1ba278f" + .parse() + .unwrap(), + vout: 1, + }, + TxOut { + value: Amount::from_sat(2000000), + // HACK: The script pubkey in the original test vector is a nested p2sh witness + // script, which is not correctly supported in our current weight calculations. + // To get around this limitation, this test uses a native segwit script instead. + script_pubkey: ScriptBuf::new_p2wpkh(&bitcoin::WPubkeyHash::hash( + "00145f806655e5924c9204c2d51be5394f4bf9eda210".as_bytes(), + )), + }, + ); + let mut payjoin = proposal + .assume_interactive_receiver() + .check_inputs_not_owned(|_| Ok(false)) + .expect("No inputs should be owned") + .check_no_mixed_input_scripts() + .expect("No mixed input scripts") + .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_witness_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 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 ec9cd819..4ffbbb6f 100644 --- a/payjoin/src/receive/v2/mod.rs +++ b/payjoin/src/receive/v2/mod.rs @@ -15,7 +15,10 @@ use serde::{Deserialize, Serialize, Serializer}; use url::Url; use super::v2::error::{InternalSessionError, SessionError}; -use super::{Error, InternalRequestError, RequestError, SelectionError}; +use super::{ + Error, InputContributionError, InternalRequestError, OutputSubstitutionError, RequestError, + SelectionError, +}; use crate::psbt::PsbtExt; use crate::receive::optional_parameters::Params; use crate::v2::OhttpEncapsulationError; @@ -383,20 +386,63 @@ impl OutputsUnknown { pub fn identify_receiver_outputs( self, is_receiver_output: impl Fn(&Script) -> Result, - ) -> Result { + ) -> Result { let inner = self.inner.identify_receiver_outputs(is_receiver_output)?; - Ok(ProvisionalProposal { inner, context: self.context }) + Ok(WantsOutputs { inner, context: self.context }) } } -/// A mutable checked proposal that the receiver may contribute inputs to to make a payjoin. +/// A checked proposal that the receiver may substitute or add outputs to #[derive(Debug, Clone)] -pub struct ProvisionalProposal { - pub inner: super::ProvisionalProposal, +pub struct WantsOutputs { + inner: super::WantsOutputs, context: SessionContext, } -impl ProvisionalProposal { +impl WantsOutputs { + pub fn is_output_substitution_disabled(&self) -> bool { + self.inner.is_output_substitution_disabled() + } + + /// Substitute the receiver output script with the provided script. + pub fn substitute_receiver_script( + self, + output_script: &Script, + ) -> Result { + let inner = self.inner.substitute_receiver_script(output_script)?; + Ok(WantsOutputs { inner, context: self.context }) + } + + /// 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 inner = self.inner.replace_receiver_outputs(replacement_outputs, drain_script)?; + Ok(WantsOutputs { 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 } + } +} + +/// A checked proposal that the receiver may contribute inputs to to make a payjoin +#[derive(Debug, Clone)] +pub struct WantsInputs { + inner: super::WantsInputs, + context: SessionContext, +} + +impl WantsInputs { /// Select receiver input such that the payjoin avoids surveillance. /// Return the input chosen that has been applied to the Proposal. /// @@ -406,8 +452,8 @@ impl ProvisionalProposal { /// UIH "Unnecessary input heuristic" is one class of them 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 - // https://eprint.iacr.org/2022/589.pdf + /// if min(in) > min(out) then UIH1 else UIH2 + /// https://eprint.iacr.org/2022/589.pdf pub fn try_preserving_privacy( &self, candidate_inputs: HashMap, @@ -415,28 +461,44 @@ impl ProvisionalProposal { self.inner.try_preserving_privacy(candidate_inputs) } - pub fn contribute_witness_input(&mut self, txo: TxOut, outpoint: OutPoint) { - self.inner.contribute_witness_input(txo, outpoint) + /// 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_witness_inputs( + self, + inputs: impl IntoIterator, + ) -> Result { + let inner = self.inner.contribute_witness_inputs(inputs)?; + Ok(WantsInputs { inner, context: self.context }) } - pub fn is_output_substitution_disabled(&self) -> bool { - self.inner.is_output_substitution_disabled() + /// 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 } } +} - /// If output substitution is enabled, replace the receiver's output script with a new one. - pub fn try_substitute_receiver_output( - &mut self, - generate_script: impl Fn() -> Result, - ) -> Result<(), Error> { - self.inner.try_substitute_receiver_output(generate_script) - } +/// A checked proposal that the receiver may sign and finalize to make a proposal PSBT that the +/// sender will accept. +#[derive(Debug, Clone)] +pub struct ProvisionalProposal { + inner: super::ProvisionalProposal, + context: SessionContext, +} +impl ProvisionalProposal { pub fn finalize_proposal( self, wallet_process_psbt: impl Fn(&Psbt) -> Result, min_feerate_sat_per_vb: Option, + max_feerate_sat_per_vb: FeeRate, ) -> Result { - let inner = self.inner.finalize_proposal(wallet_process_psbt, min_feerate_sat_per_vb)?; + let inner = self.inner.finalize_proposal( + wallet_process_psbt, + min_feerate_sat_per_vb, + max_feerate_sat_per_vb, + )?; Ok(PayjoinProposal { inner, context: self.context }) } } @@ -457,8 +519,6 @@ impl PayjoinProposal { self.inner.is_output_substitution_disabled() } - pub fn owned_vouts(&self) -> &Vec { self.inner.owned_vouts() } - pub fn psbt(&self) -> &Psbt { self.inner.psbt() } pub fn extract_v1_req(&self) -> String { self.inner.payjoin_psbt.to_string() } diff --git a/payjoin/src/send/mod.rs b/payjoin/src/send/mod.rs index 5a19f6f7..69275f6b 100644 --- a/payjoin/src/send/mod.rs +++ b/payjoin/src/send/mod.rs @@ -846,7 +846,7 @@ impl ContextV1 { ensure!(proposed_txout.value >= original_output.value, OutputValueDecreased); original_outputs.next(); } - // all original outputs processed, only additional outputs remain + // additional output _ => (), } } diff --git a/payjoin/tests/integration.rs b/payjoin/tests/integration.rs index f61f8757..6ed9292e 100644 --- a/payjoin/tests/integration.rs +++ b/payjoin/tests/integration.rs @@ -6,7 +6,7 @@ mod integration { use bitcoin::policy::DEFAULT_MIN_RELAY_TX_FEE; use bitcoin::psbt::Psbt; - use bitcoin::{Amount, FeeRate, OutPoint, Weight}; + use bitcoin::{Amount, FeeRate, OutPoint, TxOut, Weight}; use bitcoind::bitcoincore_rpc::json::{AddressType, WalletProcessPsbtResult}; use bitcoind::bitcoincore_rpc::{self, RpcApi}; use log::{log_enabled, Level}; @@ -58,7 +58,7 @@ mod integration { // ********************** // Inside the Receiver: // this data would transit from one party to another over the network in production - let response = handle_v1_pj_request(req, headers, &receiver); + let response = handle_v1_pj_request(req, headers, &receiver, None, None, None); // this response would be returned as http response to the sender // ********************** @@ -324,10 +324,7 @@ mod integration { log::info!("sent"); // Check resulting transaction and balances - // NOTE: No one is contributing fees for the receiver input because the sender has - // no change output and the receiver doesn't contribute fees. Temporary workaround - // is to ensure the sender overpays in the original psbt for the receiver's input. - let network_fees = psbt.fee()?; + let network_fees = predicted_tx_weight(&payjoin_tx) * FeeRate::BROADCAST_MIN; // Sender sent the entire value of their utxo to receiver (minus fees) assert_eq!(payjoin_tx.input.len(), 2); assert_eq!(payjoin_tx.output.len(), 1); @@ -367,7 +364,7 @@ mod integration { // ********************** // Inside the Receiver: // this data would transit from one party to another over the network in production - let response = handle_v1_pj_request(req, headers, &receiver); + let response = handle_v1_pj_request(req, headers, &receiver, None, None, None); // this response would be returned as http response to the sender // ********************** @@ -615,7 +612,7 @@ mod integration { let proposal = proposal.check_no_mixed_input_scripts().unwrap(); // Receive Check 4: have we seen this input before? More of a check for non-interactive i.e. payment processor receivers. - let mut payjoin = proposal + let payjoin = proposal .check_no_inputs_seen_before(|_| Ok(false)) .unwrap() .identify_receiver_outputs(|output_script| { @@ -626,6 +623,8 @@ mod integration { }) .expect("Receiver should have at least one output"); + let payjoin = payjoin.commit_outputs(); + // Select receiver payjoin inputs. TODO Lock them. let available_inputs = receiver.list_unspent(None, None, None, None, None).unwrap(); let candidate_inputs: HashMap = available_inputs @@ -638,19 +637,17 @@ mod integration { .iter() .find(|i| i.txid == selected_outpoint.txid && i.vout == selected_outpoint.vout) .unwrap(); - - // calculate receiver payjoin outputs given receiver payjoin inputs and original_psbt, let txo_to_contribute = bitcoin::TxOut { value: selected_utxo.amount, script_pubkey: selected_utxo.script_pub_key.clone(), }; - let outpoint_to_contribute = - bitcoin::OutPoint { txid: selected_utxo.txid, vout: selected_utxo.vout }; - payjoin.contribute_witness_input(txo_to_contribute, outpoint_to_contribute); - _ = payjoin.try_substitute_receiver_output(|| { - Ok(receiver.get_new_address(None, None).unwrap().assume_checked().script_pubkey()) - }); + let payjoin = payjoin + .contribute_witness_inputs(vec![(selected_outpoint, txo_to_contribute)]) + .unwrap() + .commit_inputs(); + + // Sign and finalize the proposal PSBT let payjoin_proposal = payjoin .finalize_proposal( |psbt: &Psbt| { @@ -668,6 +665,7 @@ mod integration { .unwrap()) }, Some(FeeRate::BROADCAST_MIN), + FeeRate::from_sat_per_vb_unchecked(2), ) .unwrap(); payjoin_proposal @@ -720,9 +718,9 @@ mod integration { outputs.insert(pj_uri.address.to_string(), Amount::from_btc(50.0)?); let options = bitcoincore_rpc::json::WalletCreateFundedPsbtOptions { lock_unspent: Some(true), - // The current API doesn't let the receiver pay for additional fees, - // so we double the minimum relay fee to ensure that the sender pays for the receiver's inputs - fee_rate: Some(Amount::from_sat((DEFAULT_MIN_RELAY_TX_FEE * 2).into())), + // The minimum relay feerate ensures that tests fail if the receiver would add inputs/outputs + // that cannot be covered by the sender's additional fee contributions. + fee_rate: Some(Amount::from_sat(DEFAULT_MIN_RELAY_TX_FEE.into())), subtract_fee_from_outputs: vec![0], ..Default::default() }; @@ -740,6 +738,197 @@ mod integration { } } + #[cfg(not(feature = "v2"))] + mod batching { + use payjoin::UriExt; + + use super::*; + + // In this test the receiver consolidates a bunch of UTXOs into the destination output + #[test] + fn receiver_consolidates_utxos() -> Result<(), BoxError> { + init_tracing(); + let (bitcoind, sender, receiver) = init_bitcoind_sender_receiver()?; + // Generate more UTXOs for the receiver + let receiver_address = + receiver.get_new_address(None, Some(AddressType::Bech32))?.assume_checked(); + bitcoind.client.generate_to_address(199, &receiver_address)?; + let receiver_utxos = receiver.list_unspent(None, None, None, None, None).unwrap(); + assert_eq!(100, receiver_utxos.len(), "receiver doesn't have enough UTXOs"); + assert_eq!( + Amount::from_btc(3700.0)?, // 48*50.0 + 52*25.0 (halving occurs every 150 blocks) + receiver.get_balances()?.mine.trusted, + "receiver doesn't have enough bitcoin" + ); + + // 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(); + + // ********************** + // Inside the Sender: + // Sender create a funded PSBT (not broadcasted) to address with amount given in the pj_uri + let uri = Uri::from_str(&pj_uri.to_string()) + .unwrap() + .assume_checked() + .check_pj_supported() + .unwrap(); + let psbt = build_original_psbt(&sender, &uri)?; + log::debug!("Original psbt: {:#?}", psbt); + let max_additional_fee = Amount::from_sat(1000); + let (req, ctx) = RequestBuilder::from_psbt_and_uri(psbt.clone(), uri)? + .build_with_additional_fee(max_additional_fee, None, FeeRate::ZERO, false)? + .extract_v1()?; + let headers = HeaderMock::new(&req.body, req.content_type); + + // ********************** + // Inside the Receiver: + // this data would transit from one party to another over the network in production + let outputs = vec![TxOut { + value: Amount::from_btc(3700.0)?, + script_pubkey: receiver + .get_new_address(None, None)? + .assume_checked() + .script_pubkey(), + }]; + let drain_script = outputs[0].script_pubkey.clone(); + let inputs = receiver_utxos + .iter() + .map(|utxo| { + let outpoint = OutPoint { txid: utxo.txid, vout: utxo.vout }; + let txo = bitcoin::TxOut { + value: utxo.amount, + script_pubkey: utxo.script_pub_key.clone(), + }; + (outpoint, txo) + }) + .collect(); + let response = handle_v1_pj_request( + req, + headers, + &receiver, + Some(outputs), + Some(&drain_script), + Some(inputs), + ); + // this response would be returned as http response to the sender + + // ********************** + // Inside the Sender: + // Sender checks, signs, finalizes, extracts, and broadcasts + let checked_payjoin_proposal_psbt = ctx.process_response(&mut response.as_bytes())?; + let payjoin_tx = extract_pj_tx(&sender, checked_payjoin_proposal_psbt)?; + sender.send_raw_transaction(&payjoin_tx)?; + + // Check resulting transaction and balances + let network_fees = predicted_tx_weight(&payjoin_tx) * FeeRate::BROADCAST_MIN; + // The sender pays (original tx fee + max additional fee) + let original_tx_fee = psbt.fee()?; + let sender_fee = original_tx_fee + max_additional_fee; + // The receiver pays the difference + let receiver_fee = network_fees - sender_fee; + assert_eq!(payjoin_tx.input.len(), 101); + assert_eq!(payjoin_tx.output.len(), 2); + assert_eq!( + receiver.get_balances()?.mine.untrusted_pending, + Amount::from_btc(3701.0)? - receiver_fee + ); + assert_eq!( + sender.get_balances()?.mine.untrusted_pending, + Amount::from_btc(49.0)? - sender_fee + ); + Ok(()) + } + + // In this test the receiver forwards part of the sender payment to another payee + #[test] + fn receiver_forwards_payment() -> Result<(), BoxError> { + init_tracing(); + let (bitcoind, sender, receiver) = init_bitcoind_sender_receiver()?; + let third_party = bitcoind.create_wallet("third-party")?; + + // 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(); + + // ********************** + // Inside the Sender: + // Sender create a funded PSBT (not broadcasted) to address with amount given in the pj_uri + let uri = Uri::from_str(&pj_uri.to_string()) + .unwrap() + .assume_checked() + .check_pj_supported() + .unwrap(); + let psbt = build_original_psbt(&sender, &uri)?; + log::debug!("Original psbt: {:#?}", psbt); + let (req, ctx) = RequestBuilder::from_psbt_and_uri(psbt.clone(), uri)? + .build_with_additional_fee(Amount::from_sat(10000), None, FeeRate::ZERO, false)? + .extract_v1()?; + let headers = HeaderMock::new(&req.body, req.content_type); + + // ********************** + // Inside the Receiver: + // this data would transit from one party to another over the network in production + let outputs = vec![ + TxOut { + value: Amount::from_sat(10000000), + script_pubkey: third_party + .get_new_address(None, None)? + .assume_checked() + .script_pubkey(), + }, + TxOut { + value: Amount::from_sat(90000000), + script_pubkey: receiver + .get_new_address(None, None)? + .assume_checked() + .script_pubkey(), + }, + ]; + let drain_script = outputs[1].script_pubkey.clone(); + let inputs = vec![]; + let response = handle_v1_pj_request( + req, + headers, + &receiver, + Some(outputs), + Some(&drain_script), + Some(inputs), + ); + // this response would be returned as http response to the sender + + // ********************** + // Inside the Sender: + // Sender checks, signs, finalizes, extracts, and broadcasts + let checked_payjoin_proposal_psbt = ctx.process_response(&mut response.as_bytes())?; + let payjoin_tx = extract_pj_tx(&sender, checked_payjoin_proposal_psbt)?; + sender.send_raw_transaction(&payjoin_tx)?; + + // Check resulting transaction and balances + let network_fees = predicted_tx_weight(&payjoin_tx) * FeeRate::BROADCAST_MIN; + // The sender pays original tx fee + let original_tx_fee = psbt.fee()?; + let sender_fee = original_tx_fee; + // The receiver pays the difference + let receiver_fee = network_fees - sender_fee; + assert_eq!(payjoin_tx.input.len(), 1); + assert_eq!(payjoin_tx.output.len(), 3); + assert_eq!( + receiver.get_balances()?.mine.untrusted_pending, + Amount::from_btc(0.9)? - receiver_fee + ); + assert_eq!(third_party.get_balances()?.mine.untrusted_pending, Amount::from_btc(0.1)?); + // sender balance is considered "trusted" because all inputs in the transaction were + // created by their wallet + assert_eq!(sender.get_balances()?.mine.trusted, Amount::from_btc(49.0)? - sender_fee); + Ok(()) + } + } + fn init_tracing() { INIT_TRACING.get_or_init(|| { let subscriber = FmtSubscriber::builder() @@ -815,6 +1004,9 @@ mod integration { req: Request, headers: impl payjoin::receive::Headers, receiver: &bitcoincore_rpc::Client, + custom_outputs: Option>, + drain_script: Option<&bitcoin::Script>, + custom_inputs: Option>, ) -> String { // Receiver receive payjoin proposal, IRL it will be an HTTP request (over ssl or onion) let proposal = payjoin::receive::UncheckedProposal::from_request( @@ -823,7 +1015,8 @@ mod integration { headers, ) .unwrap(); - let proposal = handle_proposal(proposal, receiver); + let proposal = + handle_proposal(proposal, receiver, custom_outputs, drain_script, custom_inputs); assert!(!proposal.is_output_substitution_disabled()); let psbt = proposal.psbt(); tracing::debug!("Receiver's Payjoin proposal PSBT: {:#?}", &psbt); @@ -833,6 +1026,9 @@ mod integration { fn handle_proposal( proposal: payjoin::receive::UncheckedProposal, receiver: &bitcoincore_rpc::Client, + custom_outputs: Option>, + drain_script: Option<&bitcoin::Script>, + custom_inputs: Option>, ) -> payjoin::receive::PayjoinProposal { // 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(); @@ -862,7 +1058,7 @@ mod integration { let proposal = proposal.check_no_mixed_input_scripts().unwrap(); // Receive Check 4: have we seen this input before? More of a check for non-interactive i.e. payment processor receivers. - let mut payjoin = proposal + let payjoin = proposal .check_no_inputs_seen_before(|_| Ok(false)) .unwrap() .identify_receiver_outputs(|output_script| { @@ -873,31 +1069,44 @@ mod integration { }) .expect("Receiver should have at least one output"); - // Select receiver payjoin inputs. TODO Lock them. - let available_inputs = receiver.list_unspent(None, None, None, None, None).unwrap(); - let candidate_inputs: HashMap = available_inputs - .iter() - .map(|i| (i.amount, OutPoint { txid: i.txid, vout: i.vout })) - .collect(); - - let selected_outpoint = payjoin.try_preserving_privacy(candidate_inputs).expect("gg"); - let selected_utxo = available_inputs - .iter() - .find(|i| i.txid == selected_outpoint.txid && i.vout == selected_outpoint.vout) - .unwrap(); - - // calculate receiver payjoin outputs given receiver payjoin inputs and original_psbt, - let txo_to_contribute = bitcoin::TxOut { - value: selected_utxo.amount, - script_pubkey: selected_utxo.script_pub_key.clone(), + let payjoin = match custom_outputs { + Some(txos) => payjoin + .replace_receiver_outputs(txos, &drain_script.unwrap()) + .expect("Could not substitute outputs"), + None => payjoin + .substitute_receiver_script( + &receiver.get_new_address(None, None).unwrap().assume_checked().script_pubkey(), + ) + .expect("Could not substitute outputs"), + } + .commit_outputs(); + + let inputs = match custom_inputs { + Some(inputs) => inputs, + None => { + // Select receiver payjoin inputs. TODO Lock them. + let available_inputs = receiver.list_unspent(None, None, None, None, None).unwrap(); + let candidate_inputs: HashMap = available_inputs + .iter() + .map(|i| (i.amount, OutPoint { txid: i.txid, vout: i.vout })) + .collect(); + + let selected_outpoint = + payjoin.try_preserving_privacy(candidate_inputs).expect("gg"); + let selected_utxo = available_inputs + .iter() + .find(|i| i.txid == selected_outpoint.txid && i.vout == selected_outpoint.vout) + .unwrap(); + let txo_to_contribute = bitcoin::TxOut { + value: selected_utxo.amount, + script_pubkey: selected_utxo.script_pub_key.clone(), + }; + vec![(selected_outpoint, txo_to_contribute)] + } }; - let outpoint_to_contribute = - bitcoin::OutPoint { txid: selected_utxo.txid, vout: selected_utxo.vout }; - payjoin.contribute_witness_input(txo_to_contribute, outpoint_to_contribute); - _ = payjoin.try_substitute_receiver_output(|| { - Ok(receiver.get_new_address(None, None).unwrap().assume_checked().script_pubkey()) - }); + let payjoin = payjoin.contribute_witness_inputs(inputs).unwrap().commit_inputs(); + let payjoin_proposal = payjoin .finalize_proposal( |psbt: &Psbt| { @@ -915,6 +1124,7 @@ mod integration { .unwrap()) }, Some(FeeRate::BROADCAST_MIN), + FeeRate::from_sat_per_vb_unchecked(2), ) .unwrap(); payjoin_proposal