Skip to content

Commit d84676c

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 d84676c

File tree

3 files changed

+109
-1
lines changed

3 files changed

+109
-1
lines changed

payjoin/src/receive/error.rs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -321,6 +321,8 @@ pub(crate) enum InternalSelectionError {
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 {
@@ -333,6 +335,8 @@ impl fmt::Display for SelectionError {
333335
),
334336
InternalSelectionError::NotFound =>
335337
write!(f, "No selection candidates improve privacy"),
338+
InternalSelectionError::FeeRateTooHighForConsolidation =>
339+
write!(f, "Fee rate too high for consolidation strategy"),
336340
}
337341
}
338342
}
@@ -345,6 +349,7 @@ impl error::Error for SelectionError {
345349
Empty => None,
346350
UnsupportedOutputLength => None,
347351
NotFound => None,
352+
FeeRateTooHighForConsolidation => None,
348353
}
349354
}
350355
}

payjoin/src/receive/v1/mod.rs

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -553,6 +553,61 @@ 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+
max_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 = current_fee / self.payjoin_psbt.unsigned_tx.weight();
569+
570+
// If fee rate is above threshold, return error
571+
if let Some(threshold) = max_fee_rate_threshold {
572+
if current_fee_rate > threshold {
573+
return Err(InternalSelectionError::FeeRateTooHighForConsolidation.into());
574+
}
575+
}
576+
577+
// Filter and collect valid inputs above dust limit
578+
let dust = dust_limit.unwrap_or(DEFAULT_DUST_LIMIT);
579+
let mut valid_inputs: Vec<InputPair> = candidate_inputs
580+
.into_iter()
581+
.filter(|input| input.previous_txout().value >= dust)
582+
.collect();
583+
584+
if valid_inputs.is_empty() {
585+
return Err(InternalSelectionError::Empty.into());
586+
}
587+
588+
valid_inputs.sort_by_key(|input| input.previous_txout().value);
589+
590+
// Select inputs until reaching max_inputs or target_value
591+
let max = max_inputs.unwrap_or(DEFAULT_MAX_INPUTS);
592+
let mut selected = Vec::new();
593+
let mut total = Amount::from_sat(0);
594+
595+
for input in valid_inputs {
596+
if selected.len() >= max {
597+
break;
598+
}
599+
total += input.previous_txout().value;
600+
selected.push(input);
601+
602+
if let Some(target) = target_value {
603+
if total >= target {
604+
break;
605+
}
606+
}
607+
}
608+
609+
Ok(selected)
610+
}
556611
}
557612

558613
/// A checked proposal that the receiver may sign and finalize to make a proposal PSBT that the
@@ -783,6 +838,9 @@ impl PayjoinProposal {
783838
pub fn psbt(&self) -> &Psbt { &self.payjoin_psbt }
784839
}
785840

841+
const DEFAULT_DUST_LIMIT: Amount = Amount::from_sat(546);
842+
const DEFAULT_MAX_INPUTS: usize = 3;
843+
786844
#[cfg(test)]
787845
pub(crate) mod test {
788846
use std::str::FromStr;

payjoin/src/receive/v2/mod.rs

Lines changed: 46 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,28 @@ 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).map(|input| vec![input]),
431+
SelectionStrategy::Consolidate => self.try_consolidate(
432+
candidate_inputs,
433+
dust_limit,
434+
max_inputs,
435+
target_value,
436+
max_consolidation_fee_rate,
437+
),
438+
}
439+
}
412440
/// Select receiver input such that the payjoin avoids surveillance.
413441
/// Return the input chosen that has been applied to the Proposal.
414442
///
@@ -427,6 +455,23 @@ impl WantsInputs {
427455
self.v1.try_preserving_privacy(candidate_inputs)
428456
}
429457

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

0 commit comments

Comments
 (0)