Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Contribute even when a sender makes no change #259

Merged
merged 6 commits into from
Jul 15, 2024
Merged
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
49 changes: 34 additions & 15 deletions payjoin/src/receive/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Amount, OutPoint>,
Expand All @@ -361,11 +358,28 @@ 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));
}

if self.payjoin_psbt.outputs.len() == 2 {
self.avoid_uih(candidate_inputs)
} else {
self.select_first_candidate(candidate_inputs)
}
}

/// UIH "Unnecessary input heuristic" is one class of heuristics to avoid. We define
/// UIH1 and UIH2 according to the BlockSci practice
/// BlockSci UIH1 and UIH2:
// if min(in) > min(out) then UIH1 else UIH2
// https://eprint.iacr.org/2022/589.pdf
fn avoid_uih(
&self,
candidate_inputs: HashMap<Amount, OutPoint>,
) -> Result<OutPoint, SelectionError> {
let min_original_out_sats = self
.payjoin_psbt
.unsigned_tx
Expand All @@ -382,30 +396,35 @@ impl ProvisionalProposal {
.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

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 {
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);
} else {
// The candidate conforms to UIH2: Unnecessary input
// and could be identified as a potential payjoin
continue;
}
}

// No suitable privacy preserving selection found
Err(SelectionError::from(InternalSelectionError::NotFound))
}

fn select_first_candidate(
&self,
candidate_inputs: HashMap<Amount, OutPoint>,
) -> Result<OutPoint, SelectionError> {
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
Expand Down
2 changes: 1 addition & 1 deletion payjoin/src/receive/v2.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
22 changes: 19 additions & 3 deletions payjoin/src/send/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,11 @@ pub struct RequestBuilder<'a> {
uri: PjUri<'a>,
disable_output_substitution: bool,
fee_contribution: Option<(bitcoin::Amount, Option<usize>)>,
/// 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,
}
Expand Down Expand Up @@ -108,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(min_fee_rate);
}

if let Some((additional_fee_index, fee_available)) = self
.psbt
.unsigned_tx
Expand Down Expand Up @@ -154,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.
Expand Down Expand Up @@ -187,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<RequestContext, CreateRequestError> {
pub fn build_non_incentivizing(
mut self,
min_fee_rate: FeeRate,
) -> Result<RequestContext, CreateRequestError> {
// 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()
}

Expand Down
34 changes: 27 additions & 7 deletions payjoin/tests/integration.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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_recommended(payjoin::bitcoin::FeeRate::BROADCAST_MIN)?;
let (Request { url, body, .. }, send_ctx) =
req_ctx.extract_v2(directory.to_owned())?;
let response = agent
Expand Down Expand Up @@ -826,6 +821,31 @@ mod integration {
Ok(Psbt::from_str(&psbt)?)
}

fn build_sweep_psbt(
sender: &bitcoincore_rpc::Client,
pj_uri: &PjUri,
) -> Result<Psbt, BoxError> {
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,
Expand Down
Loading