Skip to content

Commit 05f9f0e

Browse files
committed
Add consolidation input selection strategy
Add a new input selection strategy that optimizes for consolidation when fees are low. This strategy allows receivers to combine multiple UTXOs in a payjoin, subject to configurable constraints: - Maximum fee rate threshold for consolidation - Maximum number of inputs to add - Optional target value to reach - Minimum input value (dust limit) The strategy sorts candidate inputs by value and adds them until reaching either the max inputs limit or target value, but only when the current fee rate is below the specified threshold.
1 parent 3b95ff2 commit 05f9f0e

File tree

3 files changed

+116
-4
lines changed

3 files changed

+116
-4
lines changed

payjoin/src/receive/error.rs

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -313,14 +313,16 @@ impl std::error::Error for OutputSubstitutionError {
313313
#[derive(Debug)]
314314
pub struct SelectionError(InternalSelectionError);
315315

316-
#[derive(Debug)]
316+
#[derive(Debug)]
317317
pub(crate) enum InternalSelectionError {
318318
/// No candidates available for selection
319319
Empty,
320320
/// Current privacy selection implementation only supports 2-output transactions
321321
UnsupportedOutputLength,
322322
/// No selection candidates improve privacy
323323
NotFound,
324+
/// Fee rate too high for consolidation
325+
FeeRateTooHighForConsolidation,
324326
}
325327

326328
impl fmt::Display for SelectionError {
@@ -331,8 +333,9 @@ impl fmt::Display for SelectionError {
331333
f,
332334
"Current privacy selection implementation only supports 2-output transactions"
333335
),
334-
InternalSelectionError::NotFound =>
335-
write!(f, "No selection candidates improve privacy"),
336+
InternalSelectionError::NotFound => write!(f, "No selection candidates improve privacy"),
337+
InternalSelectionError::FeeRateTooHighForConsolidation =>
338+
write!(f, "Fee rate too high for consolidation strategy"),
336339
}
337340
}
338341
}
@@ -345,6 +348,7 @@ impl error::Error for SelectionError {
345348
Empty => None,
346349
UnsupportedOutputLength => None,
347350
NotFound => None,
351+
FeeRateTooHighForConsolidation => None,
348352
}
349353
}
350354
}

payjoin/src/receive/v1/mod.rs

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -553,6 +553,64 @@ impl WantsInputs {
553553
change_vout: self.change_vout,
554554
}
555555
}
556+
557+
/// Select inputs optimizing for consolidation when fees are low
558+
pub(crate) fn try_consolidate(
559+
&self,
560+
candidate_inputs: impl IntoIterator<Item = InputPair>,
561+
dust_limit: Option<Amount>,
562+
max_inputs: Option<usize>,
563+
target_value: Option<Amount>,
564+
fee_rate_threshold: Option<FeeRate>,
565+
) -> Result<Vec<InputPair>, SelectionError> {
566+
// Check current fee rate
567+
let current_fee = self.payjoin_psbt.fee().expect("fee exists");
568+
let current_fee_rate = FeeRate::from_sat_per_vb(
569+
current_fee.to_sat() / self.payjoin_psbt.unsigned_tx.weight().to_vbytes_ceil(),
570+
);
571+
572+
// If fee rate is above threshold, return error
573+
if let (Some(current), Some(threshold)) = (current_fee_rate, fee_rate_threshold) {
574+
if current > threshold {
575+
return Err(InternalSelectionError::FeeRateTooHighForConsolidation.into());
576+
}
577+
}
578+
579+
// Filter and collect valid inputs above dust limit
580+
let dust = dust_limit.unwrap_or(DEFAULT_DUST_LIMIT);
581+
let mut valid_inputs: Vec<InputPair> = candidate_inputs
582+
.into_iter()
583+
.filter(|input| input.previous_txout().value >= dust)
584+
.collect();
585+
586+
if valid_inputs.is_empty() {
587+
return Err(InternalSelectionError::Empty.into());
588+
}
589+
590+
// Sort inputs by value ascending
591+
valid_inputs.sort_by_key(|input| input.previous_txout().value);
592+
593+
// Select inputs until reaching max_inputs or target_value
594+
let max = max_inputs.unwrap_or(DEFAULT_MAX_INPUTS);
595+
let mut selected = Vec::new();
596+
let mut total = Amount::from_sat(0);
597+
598+
for input in valid_inputs {
599+
if selected.len() >= max {
600+
break;
601+
}
602+
total += input.previous_txout().value;
603+
selected.push(input);
604+
605+
if let Some(target) = target_value {
606+
if total >= target {
607+
break;
608+
}
609+
}
610+
}
611+
612+
Ok(selected)
613+
}
556614
}
557615

558616
/// A checked proposal that the receiver may sign and finalize to make a proposal PSBT that the
@@ -783,6 +841,9 @@ impl PayjoinProposal {
783841
pub fn psbt(&self) -> &Psbt { &self.payjoin_psbt }
784842
}
785843

844+
const DEFAULT_DUST_LIMIT: Amount = Amount::from_sat(546);
845+
const DEFAULT_MAX_INPUTS: usize = 3;
846+
786847
#[cfg(test)]
787848
pub(crate) mod test {
788849
use std::str::FromStr;

payjoin/src/receive/v2/mod.rs

Lines changed: 48 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ use std::time::{Duration, SystemTime};
44

55
use bitcoin::hashes::{sha256, Hash};
66
use bitcoin::psbt::Psbt;
7-
use bitcoin::{Address, FeeRate, OutPoint, Script, TxOut};
7+
use bitcoin::{Address, Amount, FeeRate, OutPoint, Script, TxOut};
88
pub(crate) use error::InternalSessionError;
99
pub use error::SessionError;
1010
use serde::de::Deserializer;
@@ -28,6 +28,12 @@ const SUPPORTED_VERSIONS: &[usize] = &[1, 2];
2828

2929
static TWENTY_FOUR_HOURS_DEFAULT_EXPIRY: Duration = Duration::from_secs(60 * 60 * 24);
3030

31+
#[derive(Debug, Clone)]
32+
pub enum SelectionStrategy {
33+
PreservePrivacy,
34+
Consolidate,
35+
}
36+
3137
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
3238
struct SessionContext {
3339
#[serde(deserialize_with = "deserialize_address_assume_checked")]
@@ -409,6 +415,30 @@ pub struct WantsInputs {
409415
}
410416

411417
impl WantsInputs {
418+
/// Select receiver inputs using the specified selection strategy
419+
pub fn select_inputs(
420+
&self,
421+
candidate_inputs: impl IntoIterator<Item = InputPair>,
422+
strategy: SelectionStrategy,
423+
dust_limit: Option<Amount>,
424+
max_inputs: Option<usize>,
425+
target_value: Option<Amount>,
426+
max_consolidation_fee_rate: Option<FeeRate>,
427+
) -> Result<Vec<InputPair>, SelectionError> {
428+
match strategy {
429+
SelectionStrategy::PreservePrivacy =>
430+
self.try_preserving_privacy(candidate_inputs)
431+
.map(|input| vec![input]),
432+
SelectionStrategy::Consolidate =>
433+
self.try_consolidate(
434+
candidate_inputs,
435+
dust_limit,
436+
max_inputs,
437+
target_value,
438+
max_consolidation_fee_rate
439+
)
440+
}
441+
}
412442
/// Select receiver input such that the payjoin avoids surveillance.
413443
/// Return the input chosen that has been applied to the Proposal.
414444
///
@@ -427,6 +457,23 @@ impl WantsInputs {
427457
self.v1.try_preserving_privacy(candidate_inputs)
428458
}
429459

460+
pub fn try_consolidate(
461+
&self,
462+
candidate_inputs: impl IntoIterator<Item = InputPair>,
463+
dust_limit: Option<Amount>,
464+
max_inputs: Option<usize>,
465+
target_value: Option<Amount>,
466+
max_fee_rate: Option<FeeRate>,
467+
) -> Result<Vec<InputPair>, SelectionError> {
468+
self.v1.try_consolidate(
469+
candidate_inputs,
470+
dust_limit,
471+
max_inputs,
472+
target_value,
473+
max_fee_rate,
474+
)
475+
}
476+
430477
/// Add the provided list of inputs to the transaction.
431478
/// Any excess input amount is added to the change_vout output indicated previously.
432479
pub fn contribute_inputs(

0 commit comments

Comments
 (0)