From f2b81ff808807a53df497c053cc9d53ba63fcdfd Mon Sep 17 00:00:00 2001 From: DanGould Date: Tue, 21 May 2024 11:10:45 -0400 Subject: [PATCH 1/6] Contribute even when a sender makes no change A pure consolidation does not suffer from Unnecessary Input Heuristic. A Payjoin consolidation without sender change still breaks Common Input Heuristic. Allow it. --- payjoin/src/receive/mod.rs | 79 ++++++++++++++++++++------------------ 1 file changed, 42 insertions(+), 37 deletions(-) diff --git a/payjoin/src/receive/mod.rs b/payjoin/src/receive/mod.rs index 58acd60e..d2edea20 100644 --- a/payjoin/src/receive/mod.rs +++ b/payjoin/src/receive/mod.rs @@ -361,45 +361,50 @@ impl ProvisionalProposal { return Err(SelectionError::from(InternalSelectionError::Empty)); } - if self.payjoin_psbt.outputs.len() != 2 { - // Current UIH techniques only support many-input, two-output transactions. + 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)); - } - - let min_original_out_sats = self - .payjoin_psbt - .unsigned_tx - .output - .iter() - .map(|output| output.value) - .min() - .unwrap_or_else(|| Amount::MAX_MONEY.to_sat()); - - 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.to_sat()); - - // Assume many-input, two output to select the vout for now - let prior_payment_sats = self.payjoin_psbt.unsigned_tx.output[self.owned_vouts[0]].value; - for candidate in candidate_inputs { - // TODO bound loop by timeout / iterations - - let candidate_sats = candidate.0.to_sat(); - let candidate_min_out = min(min_original_out_sats, prior_payment_sats + candidate_sats); - let candidate_min_in = min(min_original_in_sats, candidate_sats); - - if candidate_min_out < candidate_min_in { - // The candidate avoids UIH2 but conforms to UIH1: Optimal change heuristic. - // It implies the smallest output is the sender's change address. - return Ok(candidate.1); - } else { - // The candidate conforms to UIH2: Unnecessary input - // and could be identified as a potential payjoin - continue; + } else if self.payjoin_psbt.outputs.len() == 2 { + let min_original_out_sats = self + .payjoin_psbt + .unsigned_tx + .output + .iter() + .map(|output| output.value) + .min() + .unwrap_or_else(|| Amount::MAX_MONEY.to_sat()); + + 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.to_sat()); + + // Assume many-input, two output to select the vout for now + let prior_payment_sats = + self.payjoin_psbt.unsigned_tx.output[self.owned_vouts[0]].value; + for candidate in candidate_inputs { + // TODO bound loop by timeout / iterations + + let candidate_sats = candidate.0.to_sat(); + let candidate_min_out = + min(min_original_out_sats, prior_payment_sats + candidate_sats); + let candidate_min_in = min(min_original_in_sats, candidate_sats); + + if candidate_min_out < candidate_min_in { + // The candidate avoids UIH2 but conforms to UIH1: Optimal change heuristic. + // It implies the smallest output is the sender's change address. + return Ok(candidate.1); + } else { + // The candidate conforms to UIH2: Unnecessary input + // and could be identified as a potential payjoin + continue; + } } + } else { + return Ok(candidate_inputs.values().next().expect("empty already checked").clone()); } // No suitable privacy preserving selection found From 906ccbfb84bafd62dce76affb7925a9ed72beb46 Mon Sep 17 00:00:00 2001 From: DanGould Date: Tue, 21 May 2024 16:24:11 -0400 Subject: [PATCH 2/6] Abstract privacy-preserving candidate selection Avoid UIH for multiple inputs, and just select any input where UIH does not apply. --- payjoin/src/receive/mod.rs | 102 +++++++++++++++++++++---------------- 1 file changed, 58 insertions(+), 44 deletions(-) diff --git a/payjoin/src/receive/mod.rs b/payjoin/src/receive/mod.rs index d2edea20..7227320c 100644 --- a/payjoin/src/receive/mod.rs +++ b/payjoin/src/receive/mod.rs @@ -348,11 +348,8 @@ impl ProvisionalProposal { /// 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 one class of them to avoid. We define - /// UIH1 and UIH2 according to the BlockSci practice - /// BlockSci UIH1 and UIH2: - // if min(out) < min(in) then UIH1 else UIH2 - // https://eprint.iacr.org/2022/589.pdf + /// 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: HashMap, @@ -365,52 +362,69 @@ impl ProvisionalProposal { // This UIH avoidance function supports only // many-input, n-output transactions such that n <= 2 for now return Err(SelectionError::from(InternalSelectionError::TooManyOutputs)); - } else if self.payjoin_psbt.outputs.len() == 2 { - let min_original_out_sats = self - .payjoin_psbt - .unsigned_tx - .output - .iter() - .map(|output| output.value) - .min() - .unwrap_or_else(|| Amount::MAX_MONEY.to_sat()); - - 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.to_sat()); - - // Assume many-input, two output to select the vout for now - let prior_payment_sats = - self.payjoin_psbt.unsigned_tx.output[self.owned_vouts[0]].value; - for candidate in candidate_inputs { - // TODO bound loop by timeout / iterations - - let candidate_sats = candidate.0.to_sat(); - let candidate_min_out = - min(min_original_out_sats, prior_payment_sats + candidate_sats); - let candidate_min_in = min(min_original_in_sats, candidate_sats); - - if candidate_min_out < candidate_min_in { - // The candidate avoids UIH2 but conforms to UIH1: Optimal change heuristic. - // It implies the smallest output is the sender's change address. - return Ok(candidate.1); - } else { - // The candidate conforms to UIH2: Unnecessary input - // and could be identified as a potential payjoin - continue; - } - } + } + + if self.payjoin_psbt.outputs.len() == 2 { + self.avoid_uih(candidate_inputs) } else { - return Ok(candidate_inputs.values().next().expect("empty already checked").clone()); + 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(out) < min(in) then UIH1 else UIH2 + // https://eprint.iacr.org/2022/589.pdf + fn avoid_uih( + &self, + candidate_inputs: HashMap, + ) -> Result { + let min_original_out_sats = self + .payjoin_psbt + .unsigned_tx + .output + .iter() + .map(|output| output.value) + .min() + .unwrap_or_else(|| Amount::MAX_MONEY.to_sat()); + + 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.to_sat()); + + let prior_payment_sats = self.payjoin_psbt.unsigned_tx.output[self.owned_vouts[0]].value; + + for candidate in candidate_inputs { + let candidate_sats = candidate.0.to_sat(); + let candidate_min_out = min(min_original_out_sats, prior_payment_sats + candidate_sats); + let candidate_min_in = min(min_original_in_sats, candidate_sats); + + if candidate_min_out < candidate_min_in { + // The candidate avoids UIH2 but conforms to UIH1: Optimal change heuristic. + // It implies the smallest output is the sender's change address. + return Ok(candidate.1); + } } // No suitable privacy preserving selection found Err(SelectionError::from(InternalSelectionError::NotFound)) } + fn select_first_candidate( + &self, + candidate_inputs: HashMap, + ) -> Result { + candidate_inputs + .values() + .next() + .cloned() + .ok_or_else(|| SelectionError::from(InternalSelectionError::NotFound)) + } + pub fn contribute_witness_input(&mut self, txo: TxOut, outpoint: OutPoint) { // The payjoin proposal must not introduce mixed input sequence numbers let original_sequence = self From b3c7e25f1b01664e2b28d0df5fccec7bf21641f9 Mon Sep 17 00:00:00 2001 From: DanGould Date: Mon, 8 Jul 2024 16:32:57 -0400 Subject: [PATCH 3/6] Reverse in/out order in comparison Inputs naturally come before outputs in bitcoin transactions. --- payjoin/src/receive/mod.rs | 4 ++-- payjoin/src/receive/v2.rs | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/payjoin/src/receive/mod.rs b/payjoin/src/receive/mod.rs index 7227320c..794fa793 100644 --- a/payjoin/src/receive/mod.rs +++ b/payjoin/src/receive/mod.rs @@ -374,7 +374,7 @@ 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(out) < min(in) then UIH1 else UIH2 + // if min(in) > min(out) then UIH1 else UIH2 // https://eprint.iacr.org/2022/589.pdf fn avoid_uih( &self, @@ -403,7 +403,7 @@ impl ProvisionalProposal { let candidate_min_out = min(min_original_out_sats, prior_payment_sats + candidate_sats); let candidate_min_in = min(min_original_in_sats, candidate_sats); - if candidate_min_out < candidate_min_in { + 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(candidate.1); diff --git a/payjoin/src/receive/v2.rs b/payjoin/src/receive/v2.rs index 88dfa555..f2add385 100644 --- a/payjoin/src/receive/v2.rs +++ b/payjoin/src/receive/v2.rs @@ -382,7 +382,7 @@ 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(out) < min(in) then UIH1 else UIH2 + // if min(in) > min(out) then UIH1 else UIH2 // https://eprint.iacr.org/2022/589.pdf pub fn try_preserving_privacy( &self, From 285ecd6518f0a3a7f1936e3ec04f82d0dfac43c6 Mon Sep 17 00:00:00 2001 From: DanGould Date: Mon, 8 Jul 2024 16:56:53 -0400 Subject: [PATCH 4/6] Test non-incentivizing sweep payjoin Such one-output transactions pay the base costs to transact and provide an incentive for a receiver to contribute with reduced marginal cost of adding an input rather than an explicit change output. Allowing payjoin sweeps breaks common input heuristic for them. Sweeps with many inputs are common, making ~5.2% or ~54M of ~1.037B monetary, non-coinbase txs. Source monetary non-coinbase transactions count: https://blockchair.com/bitcoin/transactions?q=is_coinbase(false)#f=hash,is_coinbase,time Source sweep transactions count: https://blockchair.com/bitcoin/transactions?s=time(desc)&q=is_coinbase(false),input_count(2..),output_count(1)#f=hash,time --- payjoin/src/send/mod.rs | 5 +++++ payjoin/tests/integration.rs | 34 +++++++++++++++++++++++++++------- 2 files changed, 32 insertions(+), 7 deletions(-) diff --git a/payjoin/src/send/mod.rs b/payjoin/src/send/mod.rs index 1f247bfc..2da0b4d2 100644 --- a/payjoin/src/send/mod.rs +++ b/payjoin/src/send/mod.rs @@ -62,6 +62,11 @@ pub struct RequestBuilder<'a> { uri: PjUri<'a>, disable_output_substitution: bool, fee_contribution: Option<(bitcoin::Amount, Option)>, + /// Decreases the fee contribution instead of erroring. + /// + /// If this option is true and a transaction with change amount lower than fee + /// contribution is provided then instead of returning error the fee contribution will + /// be just lowered in the request to match the change amount. clamp_fee_contribution: bool, min_fee_rate: FeeRate, } diff --git a/payjoin/tests/integration.rs b/payjoin/tests/integration.rs index 4397816d..be233279 100644 --- a/payjoin/tests/integration.rs +++ b/payjoin/tests/integration.rs @@ -402,14 +402,9 @@ mod integration { .assume_checked() .check_pj_supported() .unwrap(); - let psbt = build_original_psbt(&sender, &pj_uri)?; + let psbt = build_sweep_psbt(&sender, &pj_uri)?; let mut req_ctx = RequestBuilder::from_psbt_and_uri(psbt.clone(), pj_uri.clone())? - .build_with_additional_fee( - Amount::from_sat(10000), - None, - FeeRate::ZERO, - false, - )?; + .build_non_incentivizing()?; let (Request { url, body, .. }, send_ctx) = req_ctx.extract_v2(directory.to_owned())?; let response = agent @@ -826,6 +821,31 @@ mod integration { Ok(Psbt::from_str(&psbt)?) } + fn build_sweep_psbt( + sender: &bitcoincore_rpc::Client, + pj_uri: &PjUri, + ) -> Result { + let mut outputs = HashMap::with_capacity(1); + outputs.insert(pj_uri.address.to_string(), Amount::from_btc(50.0)?); + let options = bitcoincore_rpc::json::WalletCreateFundedPsbtOptions { + lock_unspent: Some(true), + fee_rate: Some(Amount::from_sat(2000)), + subtract_fee_from_outputs: vec![0], + ..Default::default() + }; + let psbt = sender + .wallet_create_funded_psbt( + &[], // inputs + &outputs, + None, // locktime + Some(options), + Some(true), // check that the sender properly clears keypaths + )? + .psbt; + let psbt = sender.wallet_process_psbt(&psbt, None, None, None)?.psbt; + Ok(Psbt::from_str(&psbt)?) + } + fn extract_pj_tx( sender: &bitcoincore_rpc::Client, psbt: Psbt, From 54f25f9be6231bd61e46836689f79702b37f7926 Mon Sep 17 00:00:00 2001 From: DanGould Date: Tue, 9 Jul 2024 11:27:56 -0400 Subject: [PATCH 5/6] Recommend non-incentivising params for sweep Since these changes makes sweeps first-class payjoin transactions, make the Requestbuilder recommend the appropriate parameters for sweeps too. --- payjoin/src/send/mod.rs | 8 ++++++++ payjoin/tests/integration.rs | 2 +- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/payjoin/src/send/mod.rs b/payjoin/src/send/mod.rs index 2da0b4d2..fd9b5070 100644 --- a/payjoin/src/send/mod.rs +++ b/payjoin/src/send/mod.rs @@ -113,6 +113,14 @@ impl<'a> RequestBuilder<'a> { // TODO support optional batched payout scripts. This would require a change to // build() which now checks for a single payee. let mut payout_scripts = std::iter::once(self.uri.address.script_pubkey()); + + // Check if the PSBT is a sweep transaction with only one output that's a payout script and no change + if self.psbt.unsigned_tx.output.len() == 1 + && payout_scripts.all(|script| script == self.psbt.unsigned_tx.output[0].script_pubkey) + { + return self.build_non_incentivizing(); + } + if let Some((additional_fee_index, fee_available)) = self .psbt .unsigned_tx diff --git a/payjoin/tests/integration.rs b/payjoin/tests/integration.rs index be233279..aae2a96f 100644 --- a/payjoin/tests/integration.rs +++ b/payjoin/tests/integration.rs @@ -404,7 +404,7 @@ mod integration { .unwrap(); let psbt = build_sweep_psbt(&sender, &pj_uri)?; let mut req_ctx = RequestBuilder::from_psbt_and_uri(psbt.clone(), pj_uri.clone())? - .build_non_incentivizing()?; + .build_recommended(payjoin::bitcoin::FeeRate::BROADCAST_MIN)?; let (Request { url, body, .. }, send_ctx) = req_ctx.extract_v2(directory.to_owned())?; let response = agent From 7361ba963b29b6e94709ab583abac766e6b7c29f Mon Sep 17 00:00:00 2001 From: DanGould Date: Tue, 9 Jul 2024 11:36:15 -0400 Subject: [PATCH 6/6] Enforce min_fee_rate on build_non_incentivizing Without this, a sender would sign a receiver transaction that would get stuck. --- payjoin/src/send/mod.rs | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/payjoin/src/send/mod.rs b/payjoin/src/send/mod.rs index fd9b5070..84c14e23 100644 --- a/payjoin/src/send/mod.rs +++ b/payjoin/src/send/mod.rs @@ -118,7 +118,7 @@ impl<'a> RequestBuilder<'a> { if self.psbt.unsigned_tx.output.len() == 1 && payout_scripts.all(|script| script == self.psbt.unsigned_tx.output[0].script_pubkey) { - return self.build_non_incentivizing(); + return self.build_non_incentivizing(min_fee_rate); } if let Some((additional_fee_index, fee_available)) = self @@ -167,7 +167,7 @@ impl<'a> RequestBuilder<'a> { false, ); } - self.build_non_incentivizing() + self.build_non_incentivizing(min_fee_rate) } /// Offer the receiver contribution to pay for his input. @@ -200,12 +200,15 @@ impl<'a> RequestBuilder<'a> { /// /// While it's generally better to offer some contribution some users may wish not to. /// This function disables contribution. - pub fn build_non_incentivizing(mut self) -> Result { + pub fn build_non_incentivizing( + mut self, + min_fee_rate: FeeRate, + ) -> Result { // since this is a builder, these should already be cleared // but we'll reset them to be sure self.fee_contribution = None; self.clamp_fee_contribution = false; - self.min_fee_rate = FeeRate::ZERO; + self.min_fee_rate = min_fee_rate; self.build() }