Skip to content

Commit 5a28670

Browse files
committed
Insert additional outputs in random indices
Additional outputs should be inserted at random indices for privacy, while preserving the relative ordering of receiver/sender outputs from the original PSBT. This commit also ensures that `disable_output_substitution` checks are made for every receiver output, in the unlikely case that there are multiple receiver outputs in the original PSBT.
1 parent 455909e commit 5a28670

File tree

2 files changed

+98
-45
lines changed

2 files changed

+98
-45
lines changed

payjoin/src/receive/mod.rs

+97-44
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ mod optional_parameters;
3737
#[cfg(feature = "v2")]
3838
pub mod v2;
3939

40+
use bitcoin::secp256k1::rand::seq::SliceRandom;
4041
use bitcoin::secp256k1::rand::{self, Rng};
4142
pub use error::{Error, OutputSubstitutionError, RequestError, SelectionError};
4243
use error::{
@@ -382,62 +383,61 @@ impl WantsOutputs {
382383
drain_script: &Script,
383384
) -> Result<WantsOutputs, OutputSubstitutionError> {
384385
let mut payjoin_psbt = self.original_psbt.clone();
385-
let mut change_vout: Option<usize> = None;
386-
if self.params.disable_output_substitution {
387-
// Receiver may not change the script pubkey or decrease the value of the
388-
// their output, but can still increase the amount or add new outputs.
389-
// https://github.com/bitcoin/bips/blob/master/bip-0078.mediawiki#payment-output-substitution
390-
let original_output = &self.original_psbt.unsigned_tx.output[self.owned_vouts[0]];
391-
match replacement_outputs
392-
.iter()
393-
.find(|txo| txo.script_pubkey == original_output.script_pubkey)
394-
{
395-
Some(txo) if txo.value < original_output.value => {
396-
return Err(InternalOutputSubstitutionError::OutputSubstitutionDisabled(
397-
"Decreasing the receiver output value is not allowed",
398-
)
399-
.into());
400-
}
401-
None =>
402-
return Err(InternalOutputSubstitutionError::OutputSubstitutionDisabled(
403-
"Changing the receiver output script pubkey is not allowed",
404-
)
405-
.into()),
406-
_ => log::info!("Receiver is augmenting outputs"),
407-
}
408-
}
409386
let mut outputs = vec![];
410387
let mut replacement_outputs = replacement_outputs.clone();
411388
let mut rng = rand::thread_rng();
412-
// Replace the existing receiver outputs
413-
for (i, output) in self.original_psbt.unsigned_tx.output.iter().enumerate() {
389+
// Substitute the existing receiver outputs, keeping the sender/receiver output ordering
390+
for (i, original_output) in self.original_psbt.unsigned_tx.output.iter().enumerate() {
414391
if self.owned_vouts.contains(&i) {
415-
// Receiver output: substitute with a provided output picked randomly
392+
// Receiver output: substitute in-place a provided replacement output
416393
if replacement_outputs.is_empty() {
417394
return Err(InternalOutputSubstitutionError::NotEnoughOutputs.into());
418395
}
419-
let index = rng.gen_range(0..replacement_outputs.len());
420-
let txo = replacement_outputs.swap_remove(index);
421-
if *drain_script == txo.script_pubkey {
422-
change_vout = Some(i);
396+
match replacement_outputs
397+
.iter()
398+
.position(|txo| txo.script_pubkey == original_output.script_pubkey)
399+
{
400+
// Select an output with the same address if one was provided
401+
Some(pos) => {
402+
let txo = replacement_outputs.swap_remove(pos);
403+
if self.params.disable_output_substitution
404+
&& txo.value < original_output.value
405+
{
406+
return Err(
407+
InternalOutputSubstitutionError::OutputSubstitutionDisabled(
408+
"Decreasing the receiver output value is not allowed",
409+
)
410+
.into(),
411+
);
412+
}
413+
outputs.push(txo);
414+
}
415+
// Otherwise randomly select one of the replacement outputs
416+
None => {
417+
if self.params.disable_output_substitution {
418+
return Err(
419+
InternalOutputSubstitutionError::OutputSubstitutionDisabled(
420+
"Changing the receiver output script pubkey is not allowed",
421+
)
422+
.into(),
423+
);
424+
}
425+
let index = rng.gen_range(0..replacement_outputs.len());
426+
let txo = replacement_outputs.swap_remove(index);
427+
outputs.push(txo);
428+
}
423429
}
424-
outputs.push(txo);
425430
} else {
426431
// Sender output: leave it as is
427-
outputs.push(output.clone());
428-
}
429-
}
430-
// Append all remaining outputs
431-
while !replacement_outputs.is_empty() {
432-
let index = rng.gen_range(0..replacement_outputs.len());
433-
let txo = replacement_outputs.swap_remove(index);
434-
if *drain_script == txo.script_pubkey {
435-
change_vout = Some(outputs.len());
432+
outputs.push(original_output.clone());
436433
}
437-
outputs.push(txo);
438-
// Additional outputs must also be added to the PSBT outputs data structure
439-
payjoin_psbt.outputs.push(Default::default());
440434
}
435+
// Insert all remaining outputs at random indices for privacy
436+
interleave_shuffle(&mut outputs, &mut replacement_outputs, &mut rng);
437+
// Identify the receiver output that will be used for change and fees
438+
let change_vout = outputs.iter().position(|txo| txo.script_pubkey == *drain_script);
439+
// Update the payjoin PSBT outputs
440+
payjoin_psbt.outputs = vec![Default::default(); outputs.len()];
441441
payjoin_psbt.unsigned_tx.output = outputs;
442442
Ok(WantsOutputs {
443443
original_psbt: self.original_psbt,
@@ -460,6 +460,34 @@ impl WantsOutputs {
460460
}
461461
}
462462

463+
/// Shuffles `new` vector, then interleaves its elements with those from `original`,
464+
/// maintaining the relative order in `original` but randomly inserting elements from `new`.
465+
/// The combined result replaces the contents of `original`.
466+
fn interleave_shuffle<T: Clone, R: rand::Rng>(
467+
original: &mut Vec<T>,
468+
new: &mut Vec<T>,
469+
rng: &mut R,
470+
) {
471+
// Shuffle the substitute_outputs
472+
new.shuffle(rng);
473+
// Create a new vector to store the combined result
474+
let mut combined = Vec::with_capacity(original.len() + new.len());
475+
// Initialize indices
476+
let mut original_index = 0;
477+
let mut new_index = 0;
478+
// Interleave elements
479+
while original_index < original.len() || new_index < new.len() {
480+
if original_index < original.len() && (new_index >= new.len() || rng.gen_bool(0.5)) {
481+
combined.push(original[original_index].clone());
482+
original_index += 1;
483+
} else {
484+
combined.push(new[new_index].clone());
485+
new_index += 1;
486+
}
487+
}
488+
*original = combined;
489+
}
490+
463491
/// A checked proposal that the receiver may contribute inputs to to make a payjoin
464492
#[derive(Debug, Clone)]
465493
pub struct WantsInputs {
@@ -840,6 +868,9 @@ impl PayjoinProposal {
840868

841869
#[cfg(test)]
842870
mod test {
871+
use rand::rngs::StdRng;
872+
use rand::SeedableRng;
873+
843874
use super::*;
844875

845876
struct MockHeaders {
@@ -915,4 +946,26 @@ mod test {
915946

916947
assert!(payjoin.is_ok(), "Payjoin should be a valid PSBT");
917948
}
949+
950+
#[test]
951+
fn test_interleave_shuffle() {
952+
let mut original1 = vec![1, 2, 3];
953+
let mut original2 = original1.clone();
954+
let mut original3 = original1.clone();
955+
let mut new1 = vec![4, 5, 6];
956+
let mut new2 = new1.clone();
957+
let mut new3 = new1.clone();
958+
let mut rng1 = StdRng::seed_from_u64(123);
959+
let mut rng2 = StdRng::seed_from_u64(234);
960+
let mut rng3 = StdRng::seed_from_u64(345);
961+
// Operate on the same data multiple times with different RNG seeds.
962+
interleave_shuffle(&mut original1, &mut new1, &mut rng1);
963+
interleave_shuffle(&mut original2, &mut new2, &mut rng2);
964+
interleave_shuffle(&mut original3, &mut new3, &mut rng3);
965+
// The result should be different for each seed
966+
// and the relative ordering from `original` always preserved/
967+
assert_eq!(original1, vec![1, 6, 2, 5, 4, 3]);
968+
assert_eq!(original2, vec![1, 5, 4, 2, 6, 3]);
969+
assert_eq!(original3, vec![4, 5, 1, 2, 6, 3]);
970+
}
918971
}

payjoin/src/send/mod.rs

+1-1
Original file line numberDiff line numberDiff line change
@@ -846,7 +846,7 @@ impl ContextV1 {
846846
ensure!(proposed_txout.value >= original_output.value, OutputValueDecreased);
847847
original_outputs.next();
848848
}
849-
// all original outputs processed, only additional outputs remain
849+
// additional output
850850
_ => (),
851851
}
852852
}

0 commit comments

Comments
 (0)