Skip to content

feat: add consolidation input selection strategy #557

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

Draft
wants to merge 1 commit into
base: master
Choose a base branch
from
Draft
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
5 changes: 5 additions & 0 deletions payjoin/src/receive/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -321,6 +321,8 @@ pub(crate) enum InternalSelectionError {
UnsupportedOutputLength,
/// No selection candidates improve privacy
NotFound,
/// Fee rate too high for consolidation
FeeRateTooHighForConsolidation,
}

impl fmt::Display for SelectionError {
Expand All @@ -333,6 +335,8 @@ impl fmt::Display for SelectionError {
),
InternalSelectionError::NotFound =>
write!(f, "No selection candidates improve privacy"),
InternalSelectionError::FeeRateTooHighForConsolidation =>
write!(f, "Fee rate too high for consolidation strategy"),
}
}
}
Expand All @@ -345,6 +349,7 @@ impl error::Error for SelectionError {
Empty => None,
UnsupportedOutputLength => None,
NotFound => None,
FeeRateTooHighForConsolidation => None,
}
}
}
Expand Down
58 changes: 58 additions & 0 deletions payjoin/src/receive/v1/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -553,6 +553,61 @@ impl WantsInputs {
change_vout: self.change_vout,
}
}

/// Select inputs optimizing for consolidation when fees are low
pub(crate) fn try_consolidate(
&self,
candidate_inputs: impl IntoIterator<Item = InputPair>,
dust_limit: Option<Amount>,
max_inputs: Option<usize>,
target_value: Option<Amount>,
max_fee_rate: Option<FeeRate>,
) -> Result<Vec<InputPair>, SelectionError> {
// Check current fee rate
let current_fee = self.payjoin_psbt.fee().expect("fee exists");
let current_fee_rate = current_fee / self.payjoin_psbt.unsigned_tx.weight();

// If fee rate is above threshold, return error
if let Some(threshold) = max_fee_rate {
if current_fee_rate > threshold {
return Err(InternalSelectionError::FeeRateTooHighForConsolidation.into());
}
}

// Filter and collect valid inputs above dust limit
let dust = dust_limit.unwrap_or(DEFAULT_DUST_LIMIT);
let mut valid_inputs: Vec<InputPair> = candidate_inputs
.into_iter()
.filter(|input| input.previous_txout().value >= dust)
.collect();

if valid_inputs.is_empty() {
return Err(InternalSelectionError::Empty.into());
}

valid_inputs.sort_by_key(|input| input.previous_txout().value);

// Select inputs until reaching max_inputs or target_value
let max = max_inputs.unwrap_or(DEFAULT_MAX_INPUTS);
let mut selected = Vec::new();
let mut total = Amount::from_sat(0);

for input in valid_inputs {
if selected.len() >= max {
break;
}
total += input.previous_txout().value;
selected.push(input);

if let Some(target) = target_value {
if total >= target {
break;
}
}
}

Ok(selected)
}
}

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

const DEFAULT_DUST_LIMIT: Amount = Amount::from_sat(546);
const DEFAULT_MAX_INPUTS: usize = 3;

#[cfg(test)]
pub(crate) mod test {
use std::str::FromStr;
Expand Down
53 changes: 52 additions & 1 deletion payjoin/src/receive/v2/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ use std::time::{Duration, SystemTime};

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

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

#[derive(Debug, Clone)]
pub enum SelectionStrategy {
PreservePrivacy,
Consolidate,
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
struct SessionContext {
#[serde(deserialize_with = "deserialize_address_assume_checked")]
Expand Down Expand Up @@ -409,6 +415,28 @@ pub struct WantsInputs {
}

impl WantsInputs {
/// Select receiver inputs using the specified selection strategy
pub fn select_inputs(
&self,
candidate_inputs: impl IntoIterator<Item = InputPair>,
strategy: SelectionStrategy,
dust_limit: Option<Amount>,
max_inputs: Option<usize>,
target_value: Option<Amount>,
max_consolidation_fee_rate: Option<FeeRate>,
) -> Result<Vec<InputPair>, SelectionError> {
match strategy {
SelectionStrategy::PreservePrivacy =>
self.try_preserving_privacy(candidate_inputs).map(|input| vec![input]),
SelectionStrategy::Consolidate => self.try_consolidate(
candidate_inputs,
dust_limit,
max_inputs,
target_value,
max_consolidation_fee_rate,
),
}
}
/// Select receiver input such that the payjoin avoids surveillance.
/// Return the input chosen that has been applied to the Proposal.
///
Expand All @@ -427,6 +455,29 @@ impl WantsInputs {
self.v1.try_preserving_privacy(candidate_inputs)
}

/// Select multiple inputs for consolidation when fees are low.
///
/// Selects up to `max_inputs` inputs above `dust_limit`, prioritizing larger values,
/// as long as the transaction fee rate stays below `max_fee_rate`.
///
/// Returns error if fee rate exceeds threshold or no valid inputs are found.
pub fn try_consolidate(
&self,
candidate_inputs: impl IntoIterator<Item = InputPair>,
dust_limit: Option<Amount>,
max_inputs: Option<usize>,
target_value: Option<Amount>,
max_fee_rate: Option<FeeRate>,
) -> Result<Vec<InputPair>, SelectionError> {
self.v1.try_consolidate(
candidate_inputs,
dust_limit,
max_inputs,
target_value,
max_fee_rate,
)
}

/// Add the provided list of inputs to the transaction.
/// Any excess input amount is added to the change_vout output indicated previously.
pub fn contribute_inputs(
Expand Down