Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
105 changes: 94 additions & 11 deletions payjoin/src/core/receive/common/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -253,9 +253,8 @@ impl WantsInputs {
/// avoiding the Unnecessary Input Heuristic 2 (UIH2) outlined in [Unnecessary Input
/// Heuristics and PayJoin Transactions by Ghesmati et al. (2022)](https://eprint.iacr.org/2022/589).
///
/// Privacy preservation is only supported for 2-output transactions. If the PSBT has more than
/// 2 outputs or if none of the candidates are suitable for avoiding UIH2, this function
/// defaults to the first candidate in `candidate_inputs` list.
/// If the PSBT has fewer than 2 outputs, or if none of the candidates are suitable for
/// avoiding UIH2, this function defaults to the first candidate in `candidate_inputs` list.
pub fn try_preserving_privacy(
&self,
candidate_inputs: impl IntoIterator<Item = InputPair>,
Expand All @@ -267,18 +266,26 @@ impl WantsInputs {
/// Returns the candidate input which avoids the UIH2 defined in [Unnecessary Input
/// Heuristics and PayJoin Transactions by Ghesmati et al. (2022)](https://eprint.iacr.org/2022/589).
///
/// Based on the paper, we are looking for the candidate input which, when added to the
/// transaction with 2 existing outputs, results in the minimum input amount to be greater than the minimum
/// output amount. Note that when calculating the minimum output amount, we consider the
/// post-contribution amounts, and expect the output which pays to the receiver to have its
/// value increased by the amount of the candidate input.
/// An analyst who assumes output `o` is the spender's change treats input `i` as unnecessary
/// when `i <= o`, since dropping `i` would still fund every other output. The optimal change
/// heuristic (UIH1) points that assumption at the smallest output, so a transaction avoids
/// UIH2 exactly when its smallest input exceeds its smallest output, for any number of
/// outputs. Beyond 2 an analyst has more than one change hypothesis to try and this check only
/// defeats the UIH1 one, so it is correspondingly weaker there.
///
/// Errors if the transaction does not have exactly 2 outputs.
/// Both minimums are taken over post-contribution amounts.
///
/// The receiver's own output (`change_vout`) still holds its pre-contribution value, so it is
/// excluded from the minimum and folded back in increased by the candidate. That increase is
/// an upper bound — a candidate partly consumed by receiver outputs added earlier raises it by
/// less — which only ever rejects a candidate, never accepts one that fails the heuristic.
///
/// Errors below 2 outputs: with no change output there is nothing for UIH1 to identify.
pub(super) fn avoid_uih(
&self,
candidate_inputs: &[InputPair],
) -> Result<InputPair, CoinSelectionError> {
if self.proposal.payjoin_psbt.outputs.len() != 2 {
if self.proposal.payjoin_psbt.outputs.len() < 2 {
return Err(InternalCoinSelectionError::UnsupportedOutputLength.into());
}

Expand All @@ -288,7 +295,9 @@ impl WantsInputs {
.unsigned_tx
.output
.iter()
.map(|output| output.value)
.enumerate()
.filter(|(vout, _)| *vout != self.proposal.change_vout)
.map(|(_, output)| output.value)
Comment on lines +298 to +300

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why are we filtering out the change output? Doesn't this mess up our existing 2 output avoid_uih calculation? How does this help with >2 outputs?

@Jolah1 Jolah1 Sep 3, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

change_vout is the receiver's own output, not the sender's change — contribute_inputs bumps it
with output[change_vout].value += change_amount. The name misleads.

The filter is a separate bug fix, not part of un-gating the output count. avoid_uih documents a
post-contribution minimum but took the min over all outputs at pre-contribution values, so when
the receiver's output is the smallest, min(min_out, prior + candidate) discards the bumped term.
On 2 outputs: receiver 1 sat, sender 4,000,000, candidate 3,000,000 -> the real min_out is
3,000,001 against min_in 3,000,000, yet the check passes on master.

It doesn't break the 2-output path: the test vector pays the receiver its largest output
(95,983,068 vs 2,000,000), so nothing existing moves — reverting only the filter flips exactly one
test, the new one. And on its own it doesn't help >2 outputs either; the 3-output test passes either
way. Hence two commits now.

.min()
.unwrap_or(Amount::MAX_MONEY);

Expand Down Expand Up @@ -691,6 +700,80 @@ mod tests {
assert_eq!(result.unwrap(), candidate);
}

#[test]
fn avoid_uih_supports_three_outputs() {
let original = original_from_test_vector();
let mut wants_inputs = WantsOutputs::new(original, vec![0]).commit_outputs();
wants_inputs.proposal.payjoin_psbt.unsigned_tx.output[0].value =
Amount::from_sat(2_000_000);
wants_inputs.proposal.payjoin_psbt.unsigned_tx.output[1].value =
Amount::from_sat(1_000_000);
wants_inputs
.proposal
.payjoin_psbt
.unsigned_tx
.output
.push(TxOut { value: Amount::from_sat(4_000_000), script_pubkey: ScriptBuf::new() });
wants_inputs.proposal.payjoin_psbt.outputs.push(Default::default());
let candidate = candidate_input_from_test_vector(Amount::from_sat(3_000_000));

let selected = wants_inputs.avoid_uih(std::slice::from_ref(&candidate));

assert_eq!(selected.unwrap(), candidate);
}

/// The n-input, m-output shape that motivates #551: batched payments and multiparty proposals
/// carry more than one input as well as more than two outputs.
#[test]
fn avoid_uih_supports_multiple_inputs_and_outputs() {
let original = original_from_test_vector();
let mut wants_inputs = WantsOutputs::new(original, vec![0]).commit_outputs();
wants_inputs.proposal.payjoin_psbt.unsigned_tx.output[0].value =
Amount::from_sat(2_000_000);
wants_inputs.proposal.payjoin_psbt.unsigned_tx.output[1].value =
Amount::from_sat(3_500_000);
wants_inputs
.proposal
.payjoin_psbt
.unsigned_tx
.output
.push(TxOut { value: Amount::from_sat(6_000_000), script_pubkey: ScriptBuf::new() });
wants_inputs.proposal.payjoin_psbt.outputs.push(Default::default());
// Below the test vector's input, so this one sets the minimum input.
let existing = candidate_input_from_test_vector(Amount::from_sat(4_000_000));
wants_inputs.proposal.payjoin_psbt.unsigned_tx.input.push(existing.txin.clone());
wants_inputs.proposal.payjoin_psbt.inputs.push(existing.psbtin.clone());

let too_small = candidate_input_from_test_vector(Amount::from_sat(3_000_000));
let suitable = candidate_input_from_test_vector(Amount::from_sat(5_000_000));

let selected = wants_inputs.avoid_uih(&[too_small, suitable.clone()]);

// Minimum output is the 3_500_000 sat sender output. The 3_000_000 sat candidate drags the
// minimum input below it; the 5_000_000 sat one leaves the existing 4_000_000 sat input
// binding, which clears it.
assert_eq!(selected.unwrap(), suitable);
}

#[test]
fn avoid_uih_uses_post_contribution_receiver_output() {
let original = original_from_test_vector();
let mut wants_inputs = WantsOutputs::new(original, vec![0]).commit_outputs();
// Smallest output before the contribution, but not after it.
wants_inputs.proposal.payjoin_psbt.unsigned_tx.output[0].value = Amount::ONE_SAT;
wants_inputs.proposal.payjoin_psbt.unsigned_tx.output[1].value =
Amount::from_sat(4_000_000);
let candidate = candidate_input_from_test_vector(Amount::from_sat(3_000_000));

let result = wants_inputs.avoid_uih(std::slice::from_ref(&candidate));

// Post-contribution the receiver's output holds 3_000_001 sat, above the candidate.
assert_eq!(
result.unwrap_err(),
CoinSelectionError::from(InternalCoinSelectionError::NotFound)
);
}

#[test]
fn try_preserving_privacy_falls_back_when_min_in_equals_min_out() {
let original = original_from_test_vector();
Expand Down
6 changes: 2 additions & 4 deletions payjoin/src/core/receive/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -370,10 +370,8 @@ impl fmt::Display for CoinSelectionError {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
match &self.0 {
InternalCoinSelectionError::Empty => write!(f, "No candidates available for selection"),
InternalCoinSelectionError::UnsupportedOutputLength => write!(
f,
"Current privacy selection implementation only supports 2-output transactions"
),
InternalCoinSelectionError::UnsupportedOutputLength =>
write!(f, "Privacy preserving selection requires at least 2 outputs"),
InternalCoinSelectionError::NotFound =>
write!(f, "No selection candidates improve privacy"),
}
Expand Down
5 changes: 2 additions & 3 deletions payjoin/src/core/receive/v2/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1172,9 +1172,8 @@ impl Receiver<WantsInputs> {
/// avoiding the Unnecessary Input Heuristic 2 (UIH2) outlined in [Unnecessary Input
/// Heuristics and PayJoin Transactions by Ghesmati et al. (2022)](https://eprint.iacr.org/2022/589).
///
/// Privacy preservation is only supported for 2-output transactions. If the PSBT has more than
/// 2 outputs or if none of the candidates are suitable for avoiding UIH2, this function
/// defaults to the first candidate in `candidate_inputs` list.
/// If the PSBT has fewer than 2 outputs, or if none of the candidates are suitable for
/// avoiding UIH2, this function defaults to the first candidate in `candidate_inputs` list.
pub fn try_preserving_privacy(
&self,
candidate_inputs: impl IntoIterator<Item = InputPair>,
Expand Down
Loading