use bitcoin::hashes::Hash;
use bitcoin::secp256k1::PublicKey;
use bitcoin::{Amount, FeeRate, OutPoint, ScriptBuf, SignedAmount, TxOut, WScriptHash, Weight};
use crate::ln::chan_utils::{
make_funding_redeemscript, BASE_INPUT_WEIGHT, EMPTY_SCRIPT_SIG_WEIGHT,
FUNDING_TRANSACTION_WITNESS_WEIGHT,
};
use crate::ln::interactivetxs::{get_output_weight, TX_COMMON_FIELDS_WEIGHT};
use crate::ln::msgs;
use crate::ln::types::ChannelId;
use crate::ln::LN_MAX_MSG_LEN;
use crate::prelude::*;
use crate::util::native_async::MaybeSend;
use crate::util::wallet_utils::{
CoinSelection, CoinSelectionSource, CoinSelectionSourceSync, ConfirmedUtxo, Input,
};
#[derive(Debug)]
pub(super) enum FeeRateAdjustmentError {
FeeRateTooLow { target_feerate: FeeRate, min_feerate: FeeRate },
FeeRateTooHigh {
target_feerate: FeeRate,
max_feerate: FeeRate,
target_fee: Amount,
original_fee: Amount,
},
FeeBufferOverflow,
FeeBufferInsufficient { source: &'static str, available: Amount, required: Amount },
}
impl core::fmt::Display for FeeRateAdjustmentError {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
match self {
FeeRateAdjustmentError::FeeRateTooLow { target_feerate, min_feerate } => {
write!(
f,
"Target feerate {} is below our minimum {}; \
proceeding without contribution, will RBF later",
target_feerate, min_feerate,
)
},
FeeRateAdjustmentError::FeeRateTooHigh {
target_feerate,
max_feerate,
target_fee,
original_fee,
} => {
write!(
f,
"Target feerate {} exceeds our maximum {} and target fee {} exceeds original fee estimate {}",
target_feerate, max_feerate, target_fee, original_fee,
)
},
FeeRateAdjustmentError::FeeBufferOverflow => {
write!(
f,
"Arithmetic overflow when computing available fee buffer; \
proceeding without contribution",
)
},
FeeRateAdjustmentError::FeeBufferInsufficient { source, available, required } => {
write!(
f,
"Fee buffer {} ({}) is insufficient for required fee {}; \
proceeding without contribution",
available, source, required,
)
},
}
}
}
#[derive(Debug)]
pub enum FundingContributionError {
FeeRateExceedsMaximum {
feerate: FeeRate,
max_feerate: FeeRate,
},
FeeRateBelowRbfMinimum {
feerate: FeeRate,
min_rbf_feerate: FeeRate,
},
InvalidSpliceValue,
PrevTxTooLarge,
CoinSelectionFailed,
MissingCoinSelectionSource,
ManuallySelectedInputsInsufficient,
NotRbfScenario,
}
impl core::fmt::Display for FundingContributionError {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
match self {
FundingContributionError::FeeRateExceedsMaximum { feerate, max_feerate } => {
write!(f, "Feerate {} exceeds maximum {}", feerate, max_feerate)
},
FundingContributionError::FeeRateBelowRbfMinimum { feerate, min_rbf_feerate } => {
write!(f, "Feerate {} is below minimum RBF feerate {}", feerate, min_rbf_feerate)
},
FundingContributionError::InvalidSpliceValue => {
write!(
f,
"Invalid splice value (zero, empty, duplicate, exceeds limit, or overdraws balance)"
)
},
FundingContributionError::PrevTxTooLarge => {
write!(f, "Input prevtx is too large to fit in a tx_add_input message")
},
FundingContributionError::CoinSelectionFailed => {
write!(f, "Coin selection failed to find suitable inputs")
},
FundingContributionError::MissingCoinSelectionSource => {
write!(f, "Coin selection source required to build this contribution")
},
FundingContributionError::ManuallySelectedInputsInsufficient => {
write!(f, "The request cannot be satisfied using the manually selected inputs")
},
FundingContributionError::NotRbfScenario => {
write!(f, "This template cannot build an RBF contribution")
},
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct FundingTemplate {
shared_input: Option<Input>,
min_rbf_feerate: Option<FeeRate>,
prior_contribution: Option<FundingContribution>,
spliceable_balance: Amount,
}
impl FundingTemplate {
pub(super) fn new(
shared_input: Option<Input>, min_rbf_feerate: Option<FeeRate>,
prior_contribution: Option<FundingContribution>, spliceable_balance: Amount,
) -> Self {
Self { shared_input, min_rbf_feerate, prior_contribution, spliceable_balance }
}
pub fn min_rbf_feerate(&self) -> Option<FeeRate> {
self.min_rbf_feerate
}
pub fn prior_contribution(&self) -> Option<&FundingContribution> {
self.prior_contribution.as_ref()
}
pub fn with_prior_contribution(self, feerate: FeeRate, max_feerate: FeeRate) -> FundingBuilder {
FundingBuilder::new(self, feerate, max_feerate)
}
pub fn without_prior_contribution(
mut self, feerate: FeeRate, max_feerate: FeeRate,
) -> FundingBuilder {
self.prior_contribution.take();
FundingBuilder::new(self, feerate, max_feerate)
}
pub async fn splice_in<W: CoinSelectionSource + MaybeSend>(
self, value_added: Amount, min_feerate: FeeRate, max_feerate: FeeRate, wallet: W,
) -> Result<FundingContribution, FundingContributionError> {
self.with_prior_contribution(min_feerate, max_feerate)
.with_coin_selection_source(wallet)
.add_value(value_added)?
.build()
.await
}
pub fn splice_in_sync<W: CoinSelectionSourceSync>(
self, value_added: Amount, min_feerate: FeeRate, max_feerate: FeeRate, wallet: W,
) -> Result<FundingContribution, FundingContributionError> {
self.with_prior_contribution(min_feerate, max_feerate)
.with_coin_selection_source_sync(wallet)
.add_value(value_added)?
.build()
}
pub fn splice_in_inputs(
self, inputs: Vec<ConfirmedUtxo>, min_feerate: FeeRate, max_feerate: FeeRate,
) -> Result<FundingContribution, FundingContributionError> {
self.with_prior_contribution(min_feerate, max_feerate).add_inputs(inputs)?.build()
}
pub fn splice_out(
self, outputs: Vec<TxOut>, min_feerate: FeeRate, max_feerate: FeeRate,
) -> Result<FundingContribution, FundingContributionError> {
self.with_prior_contribution(min_feerate, max_feerate).add_outputs(outputs).build()
}
pub async fn rbf_prior_contribution<W: CoinSelectionSource + MaybeSend>(
self, feerate: Option<FeeRate>, max_feerate: FeeRate, wallet: W,
) -> Result<FundingContribution, FundingContributionError> {
if self.prior_contribution().is_none() {
return Err(FundingContributionError::NotRbfScenario);
}
let feerate = feerate
.or_else(|| self.min_rbf_feerate())
.ok_or(FundingContributionError::NotRbfScenario)?;
self.with_prior_contribution(feerate, max_feerate)
.with_coin_selection_source(wallet)
.build()
.await
}
pub fn rbf_prior_contribution_sync<W: CoinSelectionSourceSync>(
self, feerate: Option<FeeRate>, max_feerate: FeeRate, wallet: W,
) -> Result<FundingContribution, FundingContributionError> {
if self.prior_contribution().is_none() {
return Err(FundingContributionError::NotRbfScenario);
}
let feerate = feerate
.or_else(|| self.min_rbf_feerate())
.ok_or(FundingContributionError::NotRbfScenario)?;
self.with_prior_contribution(feerate, max_feerate)
.with_coin_selection_source_sync(wallet)
.build()
}
}
fn estimate_transaction_fee(
inputs: &[ConfirmedUtxo], outputs: &[TxOut], change_output: Option<&TxOut>, is_initiator: bool,
is_splice: bool, feerate: FeeRate,
) -> Amount {
let input_weight: u64 = inputs
.iter()
.map(|input| BASE_INPUT_WEIGHT.saturating_add(input.utxo.satisfaction_weight))
.fold(0, |total_weight, input_weight| total_weight.saturating_add(input_weight));
let output_weight: u64 = outputs
.iter()
.chain(change_output.into_iter())
.map(|txout| txout.weight().to_wu())
.fold(0, |total_weight, output_weight| total_weight.saturating_add(output_weight));
let mut weight = input_weight.saturating_add(output_weight);
if is_initiator {
weight = weight
.saturating_add(TX_COMMON_FIELDS_WEIGHT)
.saturating_add(
get_output_weight(&ScriptBuf::new_p2wsh(&WScriptHash::from_raw_hash(
Hash::all_zeros(),
)))
.to_wu(),
);
if is_splice {
weight = weight
.saturating_add(BASE_INPUT_WEIGHT)
.saturating_add(EMPTY_SCRIPT_SIG_WEIGHT)
.saturating_add(FUNDING_TRANSACTION_WITNESS_WEIGHT);
#[cfg(feature = "grind_signatures")]
{
weight -= 1;
}
}
}
Weight::from_wu(weight) * feerate
}
fn validate_inputs(inputs: &[ConfirmedUtxo]) -> Result<(), FundingContributionError> {
let mut total_value = Amount::ZERO;
for (idx, input) in inputs.iter().enumerate() {
if inputs[..idx]
.iter()
.any(|existing_input| existing_input.utxo.outpoint == input.utxo.outpoint)
{
return Err(FundingContributionError::InvalidSpliceValue);
}
use crate::util::ser::Writeable;
const MESSAGE_TEMPLATE: msgs::TxAddInput = msgs::TxAddInput {
channel_id: ChannelId([0; 32]),
serial_id: 0,
prevtx: None,
prevtx_out: 0,
sequence: 0,
shared_input_txid: None,
};
let message_len = MESSAGE_TEMPLATE.serialized_length() + input.prevtx.serialized_length();
(message_len <= LN_MAX_MSG_LEN)
.then(|| ())
.ok_or(FundingContributionError::PrevTxTooLarge)?;
total_value = match total_value.checked_add(input.utxo.output.value) {
Some(sum) if sum <= Amount::MAX_MONEY => sum,
_ => return Err(FundingContributionError::InvalidSpliceValue),
};
}
Ok(())
}
#[derive(Debug, Clone, PartialEq, Eq)]
enum FundingInputs {
CoinSelected { value_added: Amount },
ManuallySelected { inputs: Vec<ConfirmedUtxo> },
}
impl FundingInputs {
fn mode(&self) -> FundingInputMode {
match self {
FundingInputs::CoinSelected { .. } => FundingInputMode::CoinSelected,
FundingInputs::ManuallySelected { .. } => FundingInputMode::ManuallySelected,
}
}
fn is_empty(&self) -> bool {
match self {
FundingInputs::CoinSelected { value_added } => *value_added == Amount::ZERO,
FundingInputs::ManuallySelected { inputs } => inputs.is_empty(),
}
}
fn value_added(&self) -> Amount {
match self {
FundingInputs::CoinSelected { value_added } => *value_added,
FundingInputs::ManuallySelected { .. } => Amount::ZERO,
}
}
fn manually_selected_inputs(&self) -> &[ConfirmedUtxo] {
match self {
FundingInputs::ManuallySelected { inputs } => inputs,
FundingInputs::CoinSelected { .. } => &[],
}
}
}
#[derive(Copy, Clone, Debug, Hash, PartialEq, Eq)]
enum FundingInputMode {
CoinSelected,
ManuallySelected,
}
impl_writeable_tlv_based_enum!(FundingInputMode,
(1, CoinSelected) => {},
(3, ManuallySelected) => {}
);
#[derive(Debug, Clone, Hash, PartialEq, Eq)]
pub struct FundingContribution {
estimated_fee: Amount,
inputs: Vec<ConfirmedUtxo>,
outputs: Vec<TxOut>,
change_output: Option<TxOut>,
feerate: FeeRate,
max_feerate: FeeRate,
is_splice: bool,
input_mode: Option<FundingInputMode>,
}
impl_writeable_tlv_based!(FundingContribution, {
(1, estimated_fee, required),
(3, inputs, optional_vec),
(5, outputs, optional_vec),
(7, change_output, option),
(9, feerate, required),
(11, max_feerate, required),
(13, is_splice, required),
(15, input_mode, option),
});
impl FundingContribution {
pub(super) fn is_splice(&self) -> bool {
self.is_splice
}
pub(crate) fn contributed_inputs(&self) -> impl Iterator<Item = OutPoint> + '_ {
self.inputs.iter().map(|input| input.utxo.outpoint)
}
pub(crate) fn contributed_outputs(&self) -> impl Iterator<Item = &bitcoin::Script> + '_ {
self.outputs
.iter()
.chain(self.change_output.iter())
.map(|output| output.script_pubkey.as_script())
}
pub fn value_added(&self) -> Amount {
let total_input_value = self.inputs.iter().map(|i| i.utxo.output.value).sum::<Amount>();
let total_output_value = self.outputs.iter().map(|output| output.value).sum();
total_input_value
.checked_sub(total_output_value)
.and_then(|v| v.checked_sub(self.estimated_fee))
.and_then(|v| {
v.checked_sub(
self.change_output.as_ref().map_or(Amount::ZERO, |output| output.value),
)
})
.unwrap_or(Amount::ZERO)
}
pub fn estimated_fee(&self) -> Amount {
self.estimated_fee
}
pub fn inputs(&self) -> &[ConfirmedUtxo] {
&self.inputs
}
pub fn outputs(&self) -> &[TxOut] {
&self.outputs
}
pub fn change_output(&self) -> Option<&TxOut> {
self.change_output.as_ref()
}
pub fn feerate(&self) -> FeeRate {
self.feerate
}
pub fn max_feerate(&self) -> FeeRate {
self.max_feerate
}
fn amend_without_coin_selection(
self, funding_inputs: Option<FundingInputs>, outputs: &[TxOut], target_feerate: FeeRate,
max_feerate: FeeRate, spliceable_balance: Amount,
) -> Option<Self> {
let adjust_for_inputs_and_outputs = |contribution: Self,
inputs: Option<FundingInputs>,
outputs: &[TxOut]|
-> Option<Self> {
let input_mode = inputs.as_ref().map(FundingInputs::mode);
let (target_value_added, inputs) = match inputs {
None => (None, Vec::new()),
Some(FundingInputs::CoinSelected { value_added }) => {
(Some(value_added), contribution.inputs)
},
Some(FundingInputs::ManuallySelected { inputs }) => (None, inputs),
};
if inputs.is_empty() && target_value_added.unwrap_or(Amount::ZERO) != Amount::ZERO {
return None;
}
if let Some(value_added) = target_value_added {
let estimated_fee = estimate_transaction_fee(
&inputs,
&outputs,
contribution.change_output.as_ref(),
true,
contribution.is_splice,
contribution.feerate,
);
let total_output_value: Amount = outputs.iter().map(|output| output.value).sum();
let required_value =
value_added.checked_add(total_output_value)?.checked_add(estimated_fee)?;
if let Some(change_output) = contribution.change_output.as_ref() {
let dust_limit = change_output.script_pubkey.minimal_non_dust();
let total_input_value: Amount =
inputs.iter().map(|input| input.utxo.output.value).sum();
match total_input_value.checked_sub(required_value) {
Some(new_change_value) if new_change_value >= dust_limit => {
let new_change_output = TxOut {
value: new_change_value,
script_pubkey: change_output.script_pubkey.clone(),
};
return Some(FundingContribution {
estimated_fee,
inputs,
outputs: outputs.to_vec(),
change_output: Some(new_change_output),
input_mode,
..contribution
});
},
_ => {},
}
}
}
let estimated_fee_no_change = estimate_transaction_fee(
&inputs,
&outputs,
None,
true,
contribution.is_splice,
contribution.feerate,
);
Some(FundingContribution {
estimated_fee: estimated_fee_no_change,
outputs: outputs.to_vec(),
inputs,
change_output: None,
input_mode,
..contribution
})
};
let new_contribution_at_current_feerate =
adjust_for_inputs_and_outputs(self, funding_inputs, outputs)?;
let mut new_contribution_at_target_feerate = new_contribution_at_current_feerate
.at_feerate(target_feerate, spliceable_balance, true)
.ok()?;
new_contribution_at_target_feerate.max_feerate = max_feerate;
Some(new_contribution_at_target_feerate)
}
pub(super) fn into_tx_parts(self) -> (Vec<ConfirmedUtxo>, Vec<TxOut>) {
let FundingContribution { inputs, mut outputs, change_output, .. } = self;
if let Some(change_output) = change_output {
outputs.push(change_output);
}
(inputs, outputs)
}
pub(super) fn into_contributed_inputs_and_outputs(self) -> (Vec<OutPoint>, Vec<ScriptBuf>) {
let FundingContribution { inputs, outputs, change_output, .. } = self;
let contributed_inputs = inputs.into_iter().map(|input| input.utxo.outpoint).collect();
let contributed_outputs = outputs.into_iter().chain(change_output.into_iter());
(contributed_inputs, contributed_outputs.map(|output| output.script_pubkey).collect())
}
pub(crate) fn into_unique_contributions<'a>(
self, existing_inputs: impl Iterator<Item = OutPoint>,
existing_outputs: impl Iterator<Item = &'a bitcoin::Script>,
) -> Option<(Vec<OutPoint>, Vec<ScriptBuf>)> {
let FundingContribution { mut inputs, mut outputs, mut change_output, .. } = self;
for existing in existing_inputs {
inputs.retain(|input| input.outpoint() != existing);
}
for existing in existing_outputs {
outputs.retain(|output| output.script_pubkey.as_script() != existing);
if change_output
.as_ref()
.filter(|output| output.script_pubkey.as_script() == existing)
.is_some()
{
change_output.take();
}
}
if inputs.is_empty() && outputs.is_empty() && change_output.as_ref().is_none() {
None
} else {
let inputs = inputs.into_iter().map(|input| input.outpoint()).collect();
let outputs = outputs
.into_iter()
.chain(change_output.into_iter())
.map(|output| output.script_pubkey)
.collect();
Some((inputs, outputs))
}
}
fn compute_feerate_adjustment(
&self, target_feerate: FeeRate, spliceable_balance: Amount, is_initiator: bool,
) -> Result<(Amount, Option<Amount>), FeeRateAdjustmentError> {
if target_feerate < self.feerate {
return Err(FeeRateAdjustmentError::FeeRateTooLow {
target_feerate,
min_feerate: self.feerate,
});
}
if target_feerate > self.max_feerate {
let target_fee = estimate_transaction_fee(
&self.inputs,
&self.outputs,
self.change_output.as_ref(),
is_initiator,
self.is_splice,
target_feerate,
);
if target_fee > self.estimated_fee {
return Err(FeeRateAdjustmentError::FeeRateTooHigh {
target_feerate,
max_feerate: self.max_feerate,
target_fee,
original_fee: self.estimated_fee,
});
}
}
let target_fee = estimate_transaction_fee(
&self.inputs,
&self.outputs,
self.change_output.as_ref(),
is_initiator,
self.is_splice,
target_feerate,
);
if !self.inputs.is_empty() && self.input_mode == Some(FundingInputMode::CoinSelected) {
let fee_buffer = self
.estimated_fee
.checked_add(
self.change_output.as_ref().map_or(Amount::ZERO, |output| output.value),
)
.ok_or(FeeRateAdjustmentError::FeeBufferOverflow)?;
if let Some(change_output) = self.change_output.as_ref() {
let dust_limit = change_output.script_pubkey.minimal_non_dust();
if let Some(new_change_value) = fee_buffer.checked_sub(target_fee) {
if new_change_value >= dust_limit {
return Ok((target_fee, Some(new_change_value)));
}
}
let target_fee_no_change = estimate_transaction_fee(
&self.inputs,
&self.outputs,
None,
is_initiator,
self.is_splice,
target_feerate,
);
if target_fee_no_change > fee_buffer {
Err(FeeRateAdjustmentError::FeeBufferInsufficient {
source: "estimated fee + change value",
available: fee_buffer,
required: target_fee_no_change,
})
} else {
Ok((target_fee_no_change, None))
}
} else if let Some(_surplus) = fee_buffer.checked_sub(target_fee) {
Ok((target_fee, None))
} else {
Err(FeeRateAdjustmentError::FeeBufferInsufficient {
source: "estimated fee",
available: fee_buffer,
required: target_fee,
})
}
} else {
let net_value_without_fee = self.net_value_without_fee();
let fee_buffer = if net_value_without_fee.is_negative() {
spliceable_balance
.checked_sub(net_value_without_fee.unsigned_abs())
.unwrap_or(Amount::ZERO)
} else {
spliceable_balance
.checked_add(net_value_without_fee.unsigned_abs())
.ok_or(FeeRateAdjustmentError::FeeBufferOverflow)?
};
if fee_buffer < target_fee {
return Err(FeeRateAdjustmentError::FeeBufferInsufficient {
source: "channel balance",
available: fee_buffer,
required: target_fee,
});
}
Ok((target_fee, None))
}
}
fn at_feerate(
mut self, feerate: FeeRate, spliceable_balance: Amount, is_initiator: bool,
) -> Result<Self, FeeRateAdjustmentError> {
let (new_estimated_fee, new_change) =
self.compute_feerate_adjustment(feerate, spliceable_balance, is_initiator)?;
match new_change {
Some(value) => self.change_output.as_mut().unwrap().value = value,
None => self.change_output = None,
}
self.estimated_fee = new_estimated_fee;
self.feerate = feerate;
Ok(self)
}
pub(super) fn for_acceptor_at_feerate(
self, feerate: FeeRate, spliceable_balance: Amount,
) -> Result<Self, FeeRateAdjustmentError> {
self.at_feerate(feerate, spliceable_balance, false)
}
pub(super) fn for_initiator_at_feerate(
self, feerate: FeeRate, spliceable_balance: Amount,
) -> Result<Self, FeeRateAdjustmentError> {
self.at_feerate(feerate, spliceable_balance, true)
}
fn net_value_at_feerate(
&self, target_feerate: FeeRate, spliceable_balance: Amount, is_initiator: bool,
) -> Result<SignedAmount, FeeRateAdjustmentError> {
let (new_estimated_fee, new_change) =
self.compute_feerate_adjustment(target_feerate, spliceable_balance, is_initiator)?;
let prev_fee = self
.estimated_fee
.to_signed()
.expect("total input amount cannot exceed Amount::MAX_MONEY");
let prev_change = self
.change_output
.as_ref()
.map_or(Amount::ZERO, |output| output.value)
.to_signed()
.expect("total input amount cannot exceed Amount::MAX_MONEY");
let new_fee = new_estimated_fee
.to_signed()
.expect("total input amount cannot exceed Amount::MAX_MONEY");
let new_change = new_change
.unwrap_or(Amount::ZERO)
.to_signed()
.expect("total input amount cannot exceed Amount::MAX_MONEY");
let prev_net_value = self.net_value();
Ok(prev_net_value + prev_fee + prev_change - new_fee - new_change)
}
pub(super) fn net_value_for_acceptor_at_feerate(
&self, target_feerate: FeeRate, spliceable_balance: Amount,
) -> Result<SignedAmount, FeeRateAdjustmentError> {
self.net_value_at_feerate(target_feerate, spliceable_balance, false)
}
pub(super) fn net_value_for_initiator_at_feerate(
&self, target_feerate: FeeRate, spliceable_balance: Amount,
) -> Result<SignedAmount, FeeRateAdjustmentError> {
self.net_value_at_feerate(target_feerate, spliceable_balance, true)
}
pub fn net_value(&self) -> SignedAmount {
let estimated_fee = self
.estimated_fee
.to_signed()
.expect("total_input_value is validated to not exceed Amount::MAX_MONEY");
self.net_value_without_fee()
.checked_sub(estimated_fee)
.expect("all amounts are validated to not exceed Amount::MAX_MONEY")
}
fn net_value_without_fee(&self) -> SignedAmount {
let total_input_value = self
.inputs
.iter()
.map(|input| input.utxo.output.value)
.sum::<Amount>()
.to_signed()
.expect("total_input_value is validated to not exceed Amount::MAX_MONEY");
let total_output_value = self
.outputs
.iter()
.chain(self.change_output.iter())
.map(|txout| txout.value)
.sum::<Amount>()
.to_signed()
.expect("total_output_value is validated to not exceed Amount::MAX_MONEY");
total_input_value
.checked_sub(total_output_value)
.expect("all amounts are validated to not exceed Amount::MAX_MONEY")
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct NoCoinSelectionSource;
#[derive(Debug, Clone, PartialEq, Eq)]
struct AsyncCoinSelectionSource<W>(W);
#[derive(Debug, Clone, PartialEq, Eq)]
struct SyncCoinSelectionSource<W>(W);
#[derive(Debug, Clone, PartialEq, Eq)]
struct FundingBuilderInner<State> {
shared_input: Option<Input>,
min_rbf_feerate: Option<FeeRate>,
prior_contribution: Option<FundingContribution>,
spliceable_balance: Amount,
funding_inputs: Option<FundingInputs>,
outputs: Vec<TxOut>,
feerate: FeeRate,
max_feerate: FeeRate,
state: State,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct FundingBuilder(FundingBuilderInner<NoCoinSelectionSource>);
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AsyncFundingBuilder<W>(FundingBuilderInner<AsyncCoinSelectionSource<W>>);
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SyncFundingBuilder<W>(FundingBuilderInner<SyncCoinSelectionSource<W>>);
impl<State> FundingBuilderInner<State> {
fn request_matches_prior(&self, prior_contribution: &FundingContribution) -> bool {
let request_matches_prior_inputs =
match (self.funding_inputs.as_ref(), prior_contribution.input_mode) {
(
Some(FundingInputs::ManuallySelected { inputs }),
Some(FundingInputMode::ManuallySelected),
) => {
let request_inputs = inputs.iter().map(|input| input.utxo.outpoint);
let prior_inputs =
prior_contribution.inputs.iter().map(|input| input.utxo.outpoint);
request_inputs.eq(prior_inputs)
},
(
Some(FundingInputs::CoinSelected { value_added }),
Some(FundingInputMode::CoinSelected),
) => *value_added == prior_contribution.value_added(),
(None, None) => true,
_ => false,
};
request_matches_prior_inputs && self.outputs == prior_contribution.outputs
}
fn build_from_prior_contribution(
&self, contribution: FundingContribution,
) -> Result<FundingContribution, FundingContributionError> {
let input_mode = self.funding_inputs.as_ref().map(FundingInputs::mode);
if self.request_matches_prior(&contribution) {
return contribution
.for_initiator_at_feerate(self.feerate, self.spliceable_balance)
.map(|mut adjusted| {
adjusted.max_feerate = self.max_feerate;
adjusted
})
.map_err(|_| {
if input_mode == Some(FundingInputMode::ManuallySelected) {
FundingContributionError::ManuallySelectedInputsInsufficient
} else {
FundingContributionError::MissingCoinSelectionSource
}
});
}
return contribution
.amend_without_coin_selection(
self.funding_inputs.clone(),
&self.outputs,
self.feerate,
self.max_feerate,
self.spliceable_balance,
)
.ok_or_else(|| {
if input_mode == Some(FundingInputMode::ManuallySelected) {
FundingContributionError::ManuallySelectedInputsInsufficient
} else {
FundingContributionError::MissingCoinSelectionSource
}
});
}
fn try_build_without_coin_selection(
&self,
) -> Result<FundingContribution, FundingContributionError> {
if let Some(contribution) = self.prior_contribution.as_ref() {
return self.build_from_prior_contribution(contribution.clone());
}
let value_added =
self.funding_inputs.as_ref().map_or(Amount::ZERO, FundingInputs::value_added);
if value_added == Amount::ZERO {
let inputs = self
.funding_inputs
.as_ref()
.map_or(&[][..], FundingInputs::manually_selected_inputs);
let input_mode =
if inputs.is_empty() { None } else { Some(FundingInputMode::ManuallySelected) };
let estimated_fee = estimate_transaction_fee(
inputs,
&self.outputs,
None,
true,
self.shared_input.is_some(),
self.feerate,
);
let contribution = FundingContribution {
estimated_fee,
inputs: match self.funding_inputs {
Some(FundingInputs::ManuallySelected { ref inputs }) => inputs.clone(),
None | Some(FundingInputs::CoinSelected { .. }) => Vec::new(),
},
outputs: self.outputs.clone(),
change_output: None,
feerate: self.feerate,
max_feerate: self.max_feerate,
is_splice: self.shared_input.is_some(),
input_mode,
};
let net_value = contribution.net_value();
if net_value.is_negative() {
self.spliceable_balance.checked_sub(net_value.unsigned_abs()).ok_or_else(|| {
if contribution.inputs.is_empty() {
FundingContributionError::InvalidSpliceValue
} else {
FundingContributionError::ManuallySelectedInputsInsufficient
}
})?;
}
return Ok(contribution);
}
Err(FundingContributionError::MissingCoinSelectionSource)
}
fn prepare_coin_selection_request(
&self,
) -> Result<(Vec<Input>, Vec<TxOut>), FundingContributionError> {
let value_added =
self.funding_inputs.as_ref().map_or(Amount::ZERO, FundingInputs::value_added);
let dummy_pubkey = PublicKey::from_slice(&[2; 33]).unwrap();
let shared_output = bitcoin::TxOut {
value: self
.shared_input
.as_ref()
.map(|shared_input| shared_input.previous_utxo.value)
.unwrap_or(Amount::ZERO)
.checked_add(value_added)
.ok_or(FundingContributionError::InvalidSpliceValue)?,
script_pubkey: make_funding_redeemscript(&dummy_pubkey, &dummy_pubkey).to_p2wsh(),
};
let must_spend = self.shared_input.clone().map(|input| vec![input]).unwrap_or_default();
let must_pay_to = if self.outputs.is_empty() {
vec![shared_output]
} else {
self.outputs.iter().cloned().chain(core::iter::once(shared_output)).collect()
};
Ok((must_spend, must_pay_to))
}
fn validate_contribution_parameters(&self) -> Result<(), FundingContributionError> {
if self.feerate > self.max_feerate {
return Err(FundingContributionError::FeeRateExceedsMaximum {
feerate: self.feerate,
max_feerate: self.max_feerate,
});
}
if let Some(min_rbf_feerate) = self.min_rbf_feerate.as_ref() {
if self.feerate < *min_rbf_feerate {
return Err(FundingContributionError::FeeRateBelowRbfMinimum {
feerate: self.feerate,
min_rbf_feerate: *min_rbf_feerate,
});
}
}
if self.funding_inputs.as_ref().map_or(true, FundingInputs::is_empty)
&& self.outputs.is_empty()
{
return Err(FundingContributionError::InvalidSpliceValue);
}
if self.funding_inputs.as_ref().map_or(Amount::ZERO, FundingInputs::value_added)
> Amount::MAX_MONEY
{
return Err(FundingContributionError::InvalidSpliceValue);
}
validate_inputs(
self.funding_inputs.as_ref().map_or(&[][..], FundingInputs::manually_selected_inputs),
)?;
let mut value_removed = Amount::ZERO;
for (idx, output) in self.outputs.iter().enumerate() {
if self.outputs[..idx]
.iter()
.any(|existing_output| existing_output.script_pubkey == output.script_pubkey)
{
return Err(FundingContributionError::InvalidSpliceValue);
}
value_removed = match value_removed.checked_add(output.value) {
Some(sum) if sum <= Amount::MAX_MONEY => sum,
_ => return Err(FundingContributionError::InvalidSpliceValue),
};
}
Ok(())
}
}
impl FundingBuilder {
fn new(template: FundingTemplate, feerate: FeeRate, max_feerate: FeeRate) -> FundingBuilder {
let FundingTemplate {
shared_input,
min_rbf_feerate,
prior_contribution,
spliceable_balance,
} = template;
let (funding_inputs, outputs) = match prior_contribution.as_ref() {
Some(prior_contribution) => {
let funding_inputs = match prior_contribution.input_mode {
Some(FundingInputMode::ManuallySelected) => {
Some(FundingInputs::ManuallySelected {
inputs: prior_contribution.inputs.clone(),
})
},
Some(FundingInputMode::CoinSelected) => Some(FundingInputs::CoinSelected {
value_added: prior_contribution.value_added(),
}),
None => None,
};
(funding_inputs, prior_contribution.outputs.clone())
},
None => (None, Vec::new()),
};
FundingBuilder(FundingBuilderInner {
shared_input,
min_rbf_feerate,
prior_contribution,
spliceable_balance,
funding_inputs,
outputs,
feerate,
max_feerate,
state: NoCoinSelectionSource,
})
}
pub fn with_coin_selection_source<W: CoinSelectionSource + MaybeSend>(
self, wallet: W,
) -> AsyncFundingBuilder<W> {
AsyncFundingBuilder(self.0.with_state(AsyncCoinSelectionSource(wallet)))
}
pub fn with_coin_selection_source_sync<W: CoinSelectionSourceSync>(
self, wallet: W,
) -> SyncFundingBuilder<W> {
SyncFundingBuilder(self.0.with_state(SyncCoinSelectionSource(wallet)))
}
pub fn add_input(self, input: ConfirmedUtxo) -> Result<Self, FundingContributionError> {
self.0.add_input_inner(input).map(FundingBuilder)
}
pub fn add_inputs(self, inputs: Vec<ConfirmedUtxo>) -> Result<Self, FundingContributionError> {
self.0.add_inputs_inner(inputs).map(FundingBuilder)
}
pub fn remove_input(self, outpoint: &OutPoint) -> Result<Self, FundingContributionError> {
self.0.remove_input_inner(outpoint).map(FundingBuilder)
}
pub fn add_output(self, output: TxOut) -> Self {
FundingBuilder(self.0.add_output_inner(output))
}
pub fn add_outputs(self, outputs: Vec<TxOut>) -> Self {
FundingBuilder(self.0.add_outputs_inner(outputs))
}
pub fn remove_outputs(self, script_pubkey: &ScriptBuf) -> Self {
FundingBuilder(self.0.remove_outputs_inner(script_pubkey))
}
pub fn build(self) -> Result<FundingContribution, FundingContributionError> {
self.0.build_without_coin_selection()
}
}
impl<State> FundingBuilderInner<State> {
fn with_state<NewState>(self, state: NewState) -> FundingBuilderInner<NewState> {
FundingBuilderInner {
shared_input: self.shared_input,
min_rbf_feerate: self.min_rbf_feerate,
prior_contribution: self.prior_contribution,
spliceable_balance: self.spliceable_balance,
funding_inputs: self.funding_inputs,
outputs: self.outputs,
feerate: self.feerate,
max_feerate: self.max_feerate,
state,
}
}
fn add_value_inner(mut self, value: Amount) -> Result<Self, FundingContributionError> {
match &mut self.funding_inputs {
None => self.funding_inputs = Some(FundingInputs::CoinSelected { value_added: value }),
Some(FundingInputs::CoinSelected { value_added }) => {
*value_added =
Amount::from_sat(value_added.to_sat().saturating_add(value.to_sat()));
},
Some(FundingInputs::ManuallySelected { .. }) => {
return Err(FundingContributionError::InvalidSpliceValue);
},
}
Ok(self)
}
fn remove_value_inner(mut self, value: Amount) -> Result<Self, FundingContributionError> {
match &mut self.funding_inputs {
None => {},
Some(FundingInputs::CoinSelected { value_added }) => {
*value_added =
Amount::from_sat(value_added.to_sat().saturating_sub(value.to_sat()));
},
Some(FundingInputs::ManuallySelected { .. }) => {
return Err(FundingContributionError::InvalidSpliceValue);
},
}
Ok(self)
}
fn add_input_inner(mut self, input: ConfirmedUtxo) -> Result<Self, FundingContributionError> {
match &mut self.funding_inputs {
None => {
self.funding_inputs = Some(FundingInputs::ManuallySelected { inputs: vec![input] })
},
Some(FundingInputs::ManuallySelected { inputs }) => inputs.push(input),
Some(FundingInputs::CoinSelected { .. }) => {
return Err(FundingContributionError::InvalidSpliceValue);
},
}
Ok(self)
}
fn add_inputs_inner(
mut self, inputs: Vec<ConfirmedUtxo>,
) -> Result<Self, FundingContributionError> {
match &mut self.funding_inputs {
None => self.funding_inputs = Some(FundingInputs::ManuallySelected { inputs }),
Some(FundingInputs::ManuallySelected { inputs: existing_inputs }) => {
existing_inputs.extend(inputs)
},
Some(FundingInputs::CoinSelected { .. }) => {
return Err(FundingContributionError::InvalidSpliceValue);
},
}
Ok(self)
}
fn remove_input_inner(mut self, outpoint: &OutPoint) -> Result<Self, FundingContributionError> {
match &mut self.funding_inputs {
None => {},
Some(FundingInputs::ManuallySelected { inputs }) => {
inputs.retain(|input| input.utxo.outpoint != *outpoint);
},
Some(FundingInputs::CoinSelected { .. }) => {
return Err(FundingContributionError::InvalidSpliceValue);
},
}
Ok(self)
}
fn add_output_inner(mut self, output: TxOut) -> Self {
self.outputs.push(output);
self
}
fn add_outputs_inner(mut self, outputs: Vec<TxOut>) -> Self {
self.outputs.extend(outputs);
self
}
fn remove_outputs_inner(mut self, script_pubkey: &ScriptBuf) -> Self {
self.outputs.retain(|output| output.script_pubkey != *script_pubkey);
self
}
fn build_without_coin_selection(
&self,
) -> Result<FundingContribution, FundingContributionError> {
self.validate_contribution_parameters()?;
self.try_build_without_coin_selection()
}
}
impl<W> AsyncFundingBuilder<W> {
pub fn add_output(self, output: TxOut) -> Self {
AsyncFundingBuilder(self.0.add_output_inner(output))
}
pub fn add_outputs(self, outputs: Vec<TxOut>) -> Self {
AsyncFundingBuilder(self.0.add_outputs_inner(outputs))
}
pub fn remove_outputs(self, script_pubkey: &ScriptBuf) -> Self {
AsyncFundingBuilder(self.0.remove_outputs_inner(script_pubkey))
}
pub fn add_value(self, value: Amount) -> Result<Self, FundingContributionError> {
self.0.add_value_inner(value).map(AsyncFundingBuilder)
}
pub fn remove_value(self, value: Amount) -> Result<Self, FundingContributionError> {
self.0.remove_value_inner(value).map(AsyncFundingBuilder)
}
}
impl<W: CoinSelectionSource + MaybeSend> AsyncFundingBuilder<W> {
pub async fn build(self) -> Result<FundingContribution, FundingContributionError> {
let inner = self.0;
match inner.build_without_coin_selection() {
Err(FundingContributionError::MissingCoinSelectionSource) => {},
other => return other,
}
let (must_spend, must_pay_to) = inner.prepare_coin_selection_request()?;
let AsyncCoinSelectionSource(wallet) = inner.state;
let coin_selection = wallet
.select_confirmed_utxos(
None,
must_spend,
&must_pay_to,
inner.feerate.to_sat_per_kwu() as u32,
u64::MAX,
)
.await
.map_err(|_| FundingContributionError::CoinSelectionFailed)?;
let CoinSelection { confirmed_utxos: inputs, change_output } = coin_selection;
validate_inputs(&inputs)?;
let outputs = inner.outputs;
let is_splice = inner.shared_input.is_some();
let estimated_fee = estimate_transaction_fee(
&inputs,
&outputs,
change_output.as_ref(),
true,
is_splice,
inner.feerate,
);
return Ok(FundingContribution {
estimated_fee,
inputs,
outputs,
change_output,
feerate: inner.feerate,
max_feerate: inner.max_feerate,
is_splice,
input_mode: Some(FundingInputMode::CoinSelected),
});
}
}
impl<W> SyncFundingBuilder<W> {
pub fn add_output(self, output: TxOut) -> Self {
SyncFundingBuilder(self.0.add_output_inner(output))
}
pub fn add_outputs(self, outputs: Vec<TxOut>) -> Self {
SyncFundingBuilder(self.0.add_outputs_inner(outputs))
}
pub fn remove_outputs(self, script_pubkey: &ScriptBuf) -> Self {
SyncFundingBuilder(self.0.remove_outputs_inner(script_pubkey))
}
pub fn add_value(self, value: Amount) -> Result<Self, FundingContributionError> {
self.0.add_value_inner(value).map(SyncFundingBuilder)
}
pub fn remove_value(self, value: Amount) -> Result<Self, FundingContributionError> {
self.0.remove_value_inner(value).map(SyncFundingBuilder)
}
}
impl<W: CoinSelectionSourceSync> SyncFundingBuilder<W> {
pub fn build(self) -> Result<FundingContribution, FundingContributionError> {
let inner = self.0;
match inner.build_without_coin_selection() {
Err(FundingContributionError::MissingCoinSelectionSource) => {},
other => return other,
}
let (must_spend, must_pay_to) = inner.prepare_coin_selection_request()?;
let SyncCoinSelectionSource(wallet) = inner.state;
let coin_selection = wallet
.select_confirmed_utxos(
None,
must_spend,
&must_pay_to,
inner.feerate.to_sat_per_kwu() as u32,
u64::MAX,
)
.map_err(|_| FundingContributionError::CoinSelectionFailed)?;
let CoinSelection { confirmed_utxos: inputs, change_output } = coin_selection;
validate_inputs(&inputs)?;
let outputs = inner.outputs;
let is_splice = inner.shared_input.is_some();
let estimated_fee = estimate_transaction_fee(
&inputs,
&outputs,
change_output.as_ref(),
true,
is_splice,
inner.feerate,
);
return Ok(FundingContribution {
estimated_fee,
inputs,
outputs,
change_output,
feerate: inner.feerate,
max_feerate: inner.max_feerate,
is_splice,
input_mode: Some(FundingInputMode::CoinSelected),
});
}
}
#[cfg(test)]
mod tests {
use super::{
estimate_transaction_fee, FeeRateAdjustmentError, FundingBuilder, FundingContribution,
FundingContributionError, FundingInputMode, FundingTemplate, SyncCoinSelectionSource,
SyncFundingBuilder,
};
use crate::chain::ClaimId;
use crate::util::wallet_utils::{CoinSelection, CoinSelectionSourceSync, ConfirmedUtxo, Input};
use bitcoin::hashes::Hash;
use bitcoin::transaction::{Transaction, TxOut, Version};
use bitcoin::{Amount, FeeRate, Psbt, ScriptBuf, SignedAmount, WPubkeyHash, WScriptHash};
#[test]
#[rustfmt::skip]
fn test_estimate_transaction_fee() {
let one_input = [funding_input_sats(1_000)];
let two_inputs = [funding_input_sats(1_000), funding_input_sats(1_000)];
assert_eq!(
estimate_transaction_fee(&two_inputs, &[], None, true, false, FeeRate::from_sat_per_kwu(2000)),
Amount::from_sat(if cfg!(feature = "grind_signatures") { 1512 } else { 1516 }),
);
assert_eq!(
estimate_transaction_fee(&two_inputs, &[], None, true, false, FeeRate::from_sat_per_kwu(3000)),
Amount::from_sat(if cfg!(feature = "grind_signatures") { 2268 } else { 2274 }),
);
assert_eq!(
estimate_transaction_fee(&one_input, &[], None, true, false, FeeRate::from_sat_per_kwu(2000)),
Amount::from_sat(if cfg!(feature = "grind_signatures") { 970 } else { 972 }),
);
assert_eq!(
estimate_transaction_fee(&[], &[], None, true, false, FeeRate::from_sat_per_kwu(2000)),
Amount::from_sat(428),
);
assert_eq!(
estimate_transaction_fee(&[], &[], None, false, false, FeeRate::from_sat_per_kwu(2000)),
Amount::from_sat(0),
);
assert_eq!(
estimate_transaction_fee(&one_input, &[], None, true, true, FeeRate::from_sat_per_kwu(2000)),
Amount::from_sat(if cfg!(feature = "grind_signatures") { 1736 } else { 1740 }),
);
assert_eq!(
estimate_transaction_fee(&one_input, &[], None, false, true, FeeRate::from_sat_per_kwu(2000)),
Amount::from_sat(if cfg!(feature = "grind_signatures") { 542 } else { 544 }),
);
let outputs = [funding_output_sats(500)];
assert_eq!(
estimate_transaction_fee(&one_input, &outputs, None, true, true, FeeRate::from_sat_per_kwu(2000)),
Amount::from_sat(if cfg!(feature = "grind_signatures") { 1984 } else { 1988 }),
);
assert_eq!(
estimate_transaction_fee(&one_input, &outputs, None, false, true, FeeRate::from_sat_per_kwu(2000)),
Amount::from_sat(if cfg!(feature = "grind_signatures") { 790 } else { 792 }),
);
let change = funding_output_sats(1_000);
assert_eq!(
estimate_transaction_fee(&one_input, &outputs, Some(&change), true, true, FeeRate::from_sat_per_kwu(2000)),
Amount::from_sat(if cfg!(feature = "grind_signatures") { 2232 } else { 2236 }),
);
assert_eq!(
estimate_transaction_fee(&one_input, &outputs, Some(&change), false, true, FeeRate::from_sat_per_kwu(2000)),
Amount::from_sat(if cfg!(feature = "grind_signatures") { 1038 } else { 1040 }),
);
}
#[rustfmt::skip]
fn funding_input_sats(input_value_sats: u64) -> ConfirmedUtxo {
let prevout = TxOut {
value: Amount::from_sat(input_value_sats),
script_pubkey: ScriptBuf::new_p2wpkh(&WPubkeyHash::all_zeros()),
};
let prevtx = Transaction {
input: vec![], output: vec![prevout],
version: Version::TWO, lock_time: bitcoin::absolute::LockTime::ZERO,
};
ConfirmedUtxo::new_p2wpkh(prevtx, 0).unwrap()
}
fn funding_output_sats(output_value_sats: u64) -> TxOut {
TxOut {
value: Amount::from_sat(output_value_sats),
script_pubkey: ScriptBuf::new_p2wpkh(&WPubkeyHash::all_zeros()),
}
}
struct UnreachableWallet;
impl CoinSelectionSourceSync for UnreachableWallet {
fn select_confirmed_utxos(
&self, _claim_id: Option<ClaimId>, _must_spend: Vec<Input>, _must_pay_to: &[TxOut],
_target_feerate_sat_per_1000_weight: u32, _max_tx_weight: u64,
) -> Result<CoinSelection, ()> {
unreachable!("should not reach coin selection")
}
fn sign_psbt(&self, _psbt: Psbt) -> Result<Transaction, ()> {
unreachable!("should not reach signing")
}
}
struct MustPayToWallet {
utxo: ConfirmedUtxo,
change_output: Option<TxOut>,
expected_must_pay_to_values: Vec<Amount>,
}
impl CoinSelectionSourceSync for MustPayToWallet {
fn select_confirmed_utxos(
&self, _claim_id: Option<ClaimId>, _must_spend: Vec<Input>, must_pay_to: &[TxOut],
_target_feerate_sat_per_1000_weight: u32, _max_tx_weight: u64,
) -> Result<CoinSelection, ()> {
assert_eq!(
must_pay_to.iter().map(|output| output.value).collect::<Vec<_>>(),
self.expected_must_pay_to_values,
);
Ok(CoinSelection {
confirmed_utxos: vec![self.utxo.clone()],
change_output: self.change_output.clone(),
})
}
fn sign_psbt(&self, _psbt: Psbt) -> Result<Transaction, ()> {
unreachable!("should not reach signing")
}
}
#[test]
fn test_funding_builder_builds_splice_out_without_wallet() {
let feerate = FeeRate::from_sat_per_kwu(2000);
let output = funding_output_sats(25_000);
let contribution = FundingBuilder::new(
FundingTemplate::new(None, None, None, Amount::MAX_MONEY),
feerate,
FeeRate::MAX,
)
.add_output(output.clone())
.build()
.unwrap();
let expected_fee = estimate_transaction_fee(
&[],
std::slice::from_ref(&output),
None,
true,
false,
feerate,
);
assert!(contribution.inputs.is_empty());
assert_eq!(contribution.outputs, vec![output.clone()]);
assert!(contribution.change_output.is_none());
assert_eq!(contribution.estimated_fee, expected_fee);
assert_eq!(
contribution.net_value(),
-output.value.to_signed().unwrap() - expected_fee.to_signed().unwrap(),
);
}
#[test]
fn test_funding_builder_rejects_splice_out_over_balance() {
let feerate = FeeRate::from_sat_per_kwu(2000);
let output = funding_output_sats(25_000);
let expected_fee = estimate_transaction_fee(
&[],
std::slice::from_ref(&output),
None,
true,
false,
feerate,
);
let exact_balance = output.value + expected_fee;
let contribution = FundingTemplate::new(None, None, None, exact_balance)
.splice_out(vec![output.clone()], feerate, FeeRate::MAX)
.unwrap();
assert_eq!(contribution.net_value(), -exact_balance.to_signed().unwrap());
let result = FundingTemplate::new(None, None, None, exact_balance - Amount::from_sat(1))
.splice_out(vec![output], feerate, FeeRate::MAX);
assert!(matches!(result, Err(FundingContributionError::InvalidSpliceValue)));
}
#[test]
fn test_funding_builder_requires_wallet_for_splice_in() {
let feerate = FeeRate::from_sat_per_kwu(2000);
let builder = FundingBuilder::new(
FundingTemplate::new(None, None, None, Amount::ZERO),
feerate,
FeeRate::MAX,
);
let builder = FundingBuilder(builder.0.add_value_inner(Amount::from_sat(25_000)).unwrap());
assert!(matches!(
builder.build(),
Err(FundingContributionError::MissingCoinSelectionSource),
));
}
#[test]
fn test_funding_builder_amends_prior_by_dropping_subdust_change() {
let feerate = FeeRate::from_sat_per_kwu(2000);
let inputs = vec![funding_input_sats(100_000)];
let change = funding_output_sats(500);
let dust_limit = change.script_pubkey.minimal_non_dust();
assert!(change.value >= dust_limit);
let estimated_fee_with_change =
estimate_transaction_fee(&inputs, &[], Some(&change), true, true, feerate);
let estimated_fee_no_change =
estimate_transaction_fee(&inputs, &[], None, true, true, feerate);
let prior = FundingContribution {
estimated_fee: estimated_fee_with_change,
inputs: inputs.clone(),
outputs: vec![],
change_output: Some(change.clone()),
feerate,
max_feerate: FeeRate::MAX,
is_splice: true,
input_mode: Some(FundingInputMode::CoinSelected),
};
let delta = Amount::from_sat(change.value.to_sat() - dust_limit.to_sat() + 1);
let target_value_added = prior.value_added().checked_add(delta).unwrap();
let total_input_value: Amount = inputs.iter().map(|input| input.utxo.output.value).sum();
let remaining_change = total_input_value
.checked_sub(target_value_added.checked_add(estimated_fee_with_change).unwrap())
.unwrap();
assert_eq!(remaining_change.to_sat(), dust_limit.to_sat() - 1);
assert!(
total_input_value >= target_value_added.checked_add(estimated_fee_no_change).unwrap()
);
let builder = FundingTemplate::new(None, None, Some(prior), Amount::MAX_MONEY)
.with_prior_contribution(feerate, FeeRate::MAX);
let contribution =
FundingBuilder(builder.0.add_value_inner(delta).unwrap()).build().unwrap();
assert!(contribution.change_output.is_none());
assert_eq!(contribution.inputs, inputs);
assert!(contribution.outputs.is_empty());
assert_eq!(contribution.estimated_fee, estimated_fee_no_change);
assert_eq!(
contribution.value_added(),
total_input_value.checked_sub(estimated_fee_no_change).unwrap()
);
assert!(contribution.value_added() > target_value_added);
}
#[test]
fn test_funding_builder_remove_outputs_removes_all_matching_scripts() {
let feerate = FeeRate::from_sat_per_kwu(2000);
let removed_script = ScriptBuf::new_p2wpkh(&WPubkeyHash::all_zeros());
let kept_script = ScriptBuf::new_p2wsh(&WScriptHash::all_zeros());
let removed_output_1 =
TxOut { value: Amount::from_sat(10_000), script_pubkey: removed_script.clone() };
let removed_output_2 =
TxOut { value: Amount::from_sat(12_000), script_pubkey: removed_script.clone() };
let kept_output = TxOut { value: Amount::from_sat(15_000), script_pubkey: kept_script };
let contribution = FundingBuilder::new(
FundingTemplate::new(None, None, None, Amount::MAX_MONEY),
feerate,
FeeRate::MAX,
)
.add_output(removed_output_1)
.add_output(kept_output.clone())
.add_output(removed_output_2)
.remove_outputs(&removed_script)
.build()
.unwrap();
assert_eq!(contribution.outputs, vec![kept_output]);
}
#[test]
fn test_funding_builder_add_and_remove_value_update_request() {
let feerate = FeeRate::from_sat_per_kwu(2000);
let value_added = Amount::from_sat(15_000);
let input_template = funding_input_sats(1);
let estimated_fee = estimate_transaction_fee(
std::slice::from_ref(&input_template),
&[],
None,
true,
false,
feerate,
);
let selected_amount = value_added + estimated_fee;
let input = funding_input_sats(selected_amount.to_sat());
let wallet = MustPayToWallet {
utxo: input.clone(),
change_output: None,
expected_must_pay_to_values: vec![value_added],
};
let contribution = FundingBuilder::new(
FundingTemplate::new(None, None, None, Amount::ZERO),
feerate,
FeeRate::MAX,
)
.with_coin_selection_source_sync(wallet)
.add_value(Amount::from_sat(20_000))
.unwrap()
.add_value(Amount::from_sat(5_000))
.unwrap()
.remove_value(Amount::from_sat(10_000))
.unwrap()
.build()
.unwrap();
assert_eq!(contribution.inputs, vec![input]);
assert!(contribution.outputs.is_empty());
assert!(contribution.change_output.is_none());
assert_eq!(contribution.value_added(), value_added);
}
#[test]
fn test_coin_selection_request_funds_outputs_from_inputs() {
let feerate = FeeRate::from_sat_per_kwu(2000);
let value_added = Amount::from_sat(15_000);
let output = funding_output_sats(8_000);
let input = funding_input_sats(50_000);
let change_template = funding_output_sats(1_000);
let estimated_fee = estimate_transaction_fee(
std::slice::from_ref(&input),
std::slice::from_ref(&output),
Some(&change_template),
true,
false,
feerate,
);
let change_value = input.utxo.output.value - value_added - output.value - estimated_fee;
let wallet = MustPayToWallet {
utxo: input,
change_output: Some(TxOut {
value: change_value,
script_pubkey: change_template.script_pubkey,
}),
expected_must_pay_to_values: vec![output.value, value_added],
};
let contribution = FundingBuilder::new(
FundingTemplate::new(None, None, None, Amount::MAX_MONEY),
feerate,
FeeRate::MAX,
)
.with_coin_selection_source_sync(wallet)
.add_value(value_added)
.unwrap()
.add_output(output.clone())
.build()
.unwrap();
assert_eq!(contribution.value_added(), value_added);
assert_eq!(contribution.outputs, vec![output]);
assert_eq!(contribution.change_output.as_ref().unwrap().value, change_value);
}
#[test]
fn test_funding_builder_remove_value_saturates_at_zero() {
let feerate = FeeRate::from_sat_per_kwu(2000);
let output = funding_output_sats(8_000);
let contribution = FundingBuilder::new(
FundingTemplate::new(None, None, None, Amount::MAX_MONEY),
feerate,
FeeRate::MAX,
)
.with_coin_selection_source_sync(UnreachableWallet)
.add_value(Amount::from_sat(10_000))
.unwrap()
.remove_value(Amount::from_sat(15_000))
.unwrap()
.add_output(output.clone())
.build()
.unwrap();
assert!(contribution.inputs.is_empty());
assert_eq!(contribution.outputs, vec![output]);
assert!(contribution.change_output.is_none());
assert_eq!(contribution.value_added(), Amount::ZERO);
}
#[test]
fn test_funding_builder_builds_manual_input_contribution_without_change() {
let feerate = FeeRate::from_sat_per_kwu(2000);
let input = funding_input_sats(100_000);
let output = funding_output_sats(25_000);
let contribution = FundingTemplate::new(None, None, None, Amount::ZERO)
.without_prior_contribution(feerate, FeeRate::MAX)
.add_input(input.clone())
.unwrap()
.add_output(output.clone())
.build()
.unwrap();
let expected_fee = estimate_transaction_fee(
std::slice::from_ref(&input),
std::slice::from_ref(&output),
None,
true,
false,
feerate,
);
assert_eq!(contribution.inputs, vec![input]);
assert_eq!(contribution.outputs, vec![output.clone()]);
assert!(contribution.change_output.is_none());
assert_eq!(contribution.input_mode, Some(FundingInputMode::ManuallySelected));
assert_eq!(contribution.estimated_fee, expected_fee);
assert_eq!(
contribution.value_added(),
Amount::from_sat(100_000) - output.value - expected_fee,
);
assert_eq!(
contribution.net_value(),
Amount::from_sat(100_000).to_signed().unwrap()
- output.value.to_signed().unwrap()
- expected_fee.to_signed().unwrap(),
);
}
#[test]
fn test_funding_builder_add_inputs_builds_manual_input_contribution() {
let feerate = FeeRate::from_sat_per_kwu(2000);
let first_input = funding_input_sats(40_000);
let second_input = funding_input_sats(60_000);
let output = funding_output_sats(25_000);
let contribution = FundingTemplate::new(None, None, None, Amount::ZERO)
.without_prior_contribution(feerate, FeeRate::MAX)
.add_inputs(vec![first_input.clone(), second_input.clone()])
.unwrap()
.add_output(output.clone())
.build()
.unwrap();
let expected_fee = estimate_transaction_fee(
&[first_input.clone(), second_input.clone()],
std::slice::from_ref(&output),
None,
true,
false,
feerate,
);
assert_eq!(contribution.inputs, vec![first_input, second_input]);
assert_eq!(contribution.outputs, vec![output.clone()]);
assert!(contribution.change_output.is_none());
assert_eq!(contribution.input_mode, Some(FundingInputMode::ManuallySelected));
assert_eq!(contribution.estimated_fee, expected_fee);
assert_eq!(
contribution.value_added(),
Amount::from_sat(100_000) - output.value - expected_fee,
);
}
#[test]
fn test_funding_builder_rejects_duplicate_inputs() {
let feerate = FeeRate::from_sat_per_kwu(2000);
let input = funding_input_sats(100_000);
let result = FundingTemplate::new(None, None, None, Amount::ZERO)
.without_prior_contribution(feerate, FeeRate::MAX)
.add_inputs(vec![input.clone(), input])
.unwrap()
.build();
assert!(matches!(result, Err(FundingContributionError::InvalidSpliceValue),));
}
#[test]
fn test_funding_builder_rejects_duplicate_outputs() {
let feerate = FeeRate::from_sat_per_kwu(2000);
let first_output = funding_output_sats(25_000);
let second_output = funding_output_sats(30_000);
assert_ne!(first_output, second_output);
assert_eq!(first_output.script_pubkey, second_output.script_pubkey);
let result = FundingTemplate::new(None, None, None, Amount::MAX_MONEY)
.without_prior_contribution(feerate, FeeRate::MAX)
.add_outputs(vec![first_output, second_output])
.build();
assert!(matches!(result, Err(FundingContributionError::InvalidSpliceValue),));
}
#[test]
fn test_funding_builder_remove_input_updates_manual_input_request() {
let feerate = FeeRate::from_sat_per_kwu(2000);
let first_input = funding_input_sats(40_000);
let second_input = funding_input_sats(60_000);
let output = funding_output_sats(25_000);
let contribution = FundingTemplate::new(None, None, None, Amount::ZERO)
.without_prior_contribution(feerate, FeeRate::MAX)
.add_inputs(vec![first_input.clone(), second_input.clone()])
.unwrap()
.remove_input(&first_input.utxo.outpoint)
.unwrap()
.add_output(output.clone())
.build()
.unwrap();
let expected_fee = estimate_transaction_fee(
std::slice::from_ref(&second_input),
std::slice::from_ref(&output),
None,
true,
false,
feerate,
);
assert_eq!(contribution.inputs, vec![second_input]);
assert_eq!(contribution.outputs, vec![output.clone()]);
assert_eq!(contribution.input_mode, Some(FundingInputMode::ManuallySelected));
assert_eq!(
contribution.value_added(),
Amount::from_sat(60_000) - output.value - expected_fee,
);
}
#[test]
fn test_splice_in_inputs_builds_manual_input_contribution() {
let feerate = FeeRate::from_sat_per_kwu(2000);
let first_input = funding_input_sats(40_000);
let second_input = funding_input_sats(60_000);
let contribution = FundingTemplate::new(None, None, None, Amount::ZERO)
.splice_in_inputs(
vec![first_input.clone(), second_input.clone()],
feerate,
FeeRate::MAX,
)
.unwrap();
let expected_fee = estimate_transaction_fee(
&[first_input.clone(), second_input.clone()],
&[],
None,
true,
false,
feerate,
);
assert_eq!(contribution.inputs, vec![first_input, second_input]);
assert!(contribution.outputs.is_empty());
assert!(contribution.change_output.is_none());
assert_eq!(contribution.input_mode, Some(FundingInputMode::ManuallySelected));
assert_eq!(contribution.value_added(), Amount::from_sat(100_000) - expected_fee);
}
#[test]
fn test_splice_in_inputs_appends_to_prior_manual_inputs() {
let feerate = FeeRate::from_sat_per_kwu(2000);
let prior_input = funding_input_sats(40_000);
let additional_input = funding_input_sats(60_000);
let prior_fee = estimate_transaction_fee(
std::slice::from_ref(&prior_input),
&[],
None,
true,
false,
feerate,
);
let prior = FundingContribution {
estimated_fee: prior_fee,
inputs: vec![prior_input.clone()],
outputs: vec![],
change_output: None,
feerate,
max_feerate: FeeRate::MAX,
is_splice: false,
input_mode: Some(FundingInputMode::ManuallySelected),
};
let contribution = FundingTemplate::new(None, None, Some(prior), Amount::MAX_MONEY)
.splice_in_inputs(vec![additional_input.clone()], feerate, FeeRate::MAX)
.unwrap();
assert_eq!(contribution.inputs, vec![prior_input, additional_input]);
assert!(contribution.outputs.is_empty());
assert_eq!(contribution.input_mode, Some(FundingInputMode::ManuallySelected));
}
#[test]
fn test_sync_funding_builder_manual_inputs_insufficient_do_not_fallback_to_coin_selection() {
let feerate = FeeRate::from_sat_per_kwu(2000);
let builder = FundingTemplate::new(None, None, None, Amount::ZERO)
.without_prior_contribution(feerate, FeeRate::MAX)
.add_input(funding_input_sats(1))
.unwrap();
let builder =
SyncFundingBuilder(builder.0.with_state(SyncCoinSelectionSource(UnreachableWallet)));
assert!(matches!(
builder.build(),
Err(FundingContributionError::ManuallySelectedInputsInsufficient),
));
}
#[test]
fn test_funding_builder_rejects_manual_inputs_with_value_request() {
let feerate = FeeRate::from_sat_per_kwu(2000);
let builder = FundingTemplate::new(None, None, None, Amount::ZERO)
.without_prior_contribution(feerate, FeeRate::MAX)
.add_input(funding_input_sats(100_000))
.unwrap();
let result = builder.clone().0.add_value_inner(Amount::from_sat(1_000));
assert!(matches!(result, Err(FundingContributionError::InvalidSpliceValue),));
let builder =
SyncFundingBuilder(builder.0.with_state(SyncCoinSelectionSource(UnreachableWallet)));
let result = builder.remove_value(Amount::from_sat(1_000));
assert!(matches!(result, Err(FundingContributionError::InvalidSpliceValue),));
}
#[test]
fn test_funding_builder_rejects_manual_inputs_on_coin_selected_prior() {
let feerate = FeeRate::from_sat_per_kwu(2000);
let prior_input = funding_input_sats(100_000);
let prior_outpoint = prior_input.utxo.outpoint;
let prior = FundingContribution {
estimated_fee: Amount::from_sat(1_000),
inputs: vec![prior_input],
outputs: vec![],
change_output: Some(funding_output_sats(10_000)),
feerate,
max_feerate: FeeRate::MAX,
is_splice: false,
input_mode: Some(FundingInputMode::CoinSelected),
};
let builder = FundingTemplate::new(None, None, Some(prior), Amount::MAX_MONEY)
.with_prior_contribution(feerate, FeeRate::MAX);
assert!(matches!(
builder.clone().add_input(funding_input_sats(50_000)),
Err(FundingContributionError::InvalidSpliceValue),
));
assert!(matches!(
builder.remove_input(&prior_outpoint),
Err(FundingContributionError::InvalidSpliceValue),
));
}
#[test]
fn test_funding_builder_validates_manual_input_max_money() {
let feerate = FeeRate::from_sat_per_kwu(2000);
let inputs = vec![funding_input_sats(Amount::MAX_MONEY.to_sat()), funding_input_sats(1)];
let builder = FundingTemplate::new(None, None, None, Amount::ZERO)
.without_prior_contribution(feerate, FeeRate::MAX)
.add_inputs(inputs)
.unwrap();
assert!(matches!(builder.build(), Err(FundingContributionError::InvalidSpliceValue),));
}
#[test]
fn test_build_from_prior_manual_inputs_exact_match_reuses_and_adjusts() {
let original_feerate = FeeRate::from_sat_per_kwu(2000);
let target_feerate = FeeRate::from_sat_per_kwu(3000);
let input = funding_input_sats(100_000);
let output = funding_output_sats(20_000);
let estimated_fee = estimate_transaction_fee(
std::slice::from_ref(&input),
std::slice::from_ref(&output),
None,
true,
false,
original_feerate,
);
let prior = FundingContribution {
estimated_fee,
inputs: vec![input.clone()],
outputs: vec![output.clone()],
change_output: None,
feerate: original_feerate,
max_feerate: FeeRate::MAX,
is_splice: false,
input_mode: Some(FundingInputMode::ManuallySelected),
};
let contribution = FundingTemplate::new(None, None, Some(prior), Amount::MAX_MONEY)
.with_prior_contribution(target_feerate, FeeRate::MAX)
.build()
.unwrap();
assert_eq!(contribution.inputs, vec![input]);
assert_eq!(contribution.outputs, vec![output]);
assert_eq!(contribution.feerate, target_feerate);
assert_eq!(contribution.input_mode, Some(FundingInputMode::ManuallySelected));
}
#[test]
fn test_build_from_prior_manual_inputs_changed_request_insufficient_maps_error() {
let feerate = FeeRate::from_sat_per_kwu(2000);
let input = funding_input_sats(50_000);
let estimated_fee =
estimate_transaction_fee(std::slice::from_ref(&input), &[], None, true, false, feerate);
let prior = FundingContribution {
estimated_fee,
inputs: vec![input],
outputs: vec![],
change_output: None,
feerate,
max_feerate: FeeRate::MAX,
is_splice: false,
input_mode: Some(FundingInputMode::ManuallySelected),
};
let result = FundingTemplate::new(None, None, Some(prior), Amount::ZERO)
.with_prior_contribution(feerate, FeeRate::MAX)
.add_output(funding_output_sats(60_000))
.build();
assert!(matches!(
result,
Err(FundingContributionError::ManuallySelectedInputsInsufficient),
));
}
#[test]
fn test_for_acceptor_at_feerate_manual_inputs_balance_insufficient() {
let original_feerate = FeeRate::from_sat_per_kwu(2000);
let target_feerate = FeeRate::from_sat_per_kwu(100_000);
let inputs = vec![funding_input_sats(100_000)];
let outputs = vec![funding_output_sats(80_000)];
let net_value_without_fee = Amount::from_sat(20_000);
let estimated_fee =
estimate_transaction_fee(&inputs, &outputs, None, true, true, original_feerate);
let target_fee =
estimate_transaction_fee(&inputs, &outputs, None, false, true, target_feerate);
assert!(target_fee > net_value_without_fee);
let contribution = FundingContribution {
estimated_fee,
inputs,
outputs,
change_output: None,
feerate: original_feerate,
max_feerate: FeeRate::MAX,
is_splice: true,
input_mode: Some(FundingInputMode::ManuallySelected),
};
let holder_balance = target_fee
.checked_sub(net_value_without_fee)
.and_then(|shortfall| shortfall.checked_sub(Amount::from_sat(1)))
.unwrap();
match contribution.for_acceptor_at_feerate(target_feerate, holder_balance) {
Err(FeeRateAdjustmentError::FeeBufferInsufficient { source, available, required }) => {
assert_eq!(source, "channel balance");
assert_eq!(available, target_fee - Amount::from_sat(1));
assert_eq!(required, target_fee);
},
other => panic!("Expected channel-balance shortfall, got {other:?}"),
}
}
#[test]
fn test_for_acceptor_at_feerate_manual_inputs_balance_sufficient() {
let original_feerate = FeeRate::from_sat_per_kwu(2000);
let target_feerate = FeeRate::from_sat_per_kwu(100_000);
let inputs = vec![funding_input_sats(100_000)];
let outputs = vec![funding_output_sats(80_000)];
let net_value_without_fee = Amount::from_sat(20_000);
let estimated_fee =
estimate_transaction_fee(&inputs, &outputs, None, true, true, original_feerate);
let target_fee =
estimate_transaction_fee(&inputs, &outputs, None, false, true, target_feerate);
let contribution = FundingContribution {
estimated_fee,
inputs: inputs.clone(),
outputs: outputs.clone(),
change_output: None,
feerate: original_feerate,
max_feerate: FeeRate::MAX,
is_splice: true,
input_mode: Some(FundingInputMode::ManuallySelected),
};
let holder_balance = target_fee.checked_sub(net_value_without_fee).unwrap();
let adjusted =
contribution.for_acceptor_at_feerate(target_feerate, holder_balance).unwrap();
assert_eq!(adjusted.inputs, inputs);
assert_eq!(adjusted.outputs, outputs);
assert_eq!(adjusted.estimated_fee, target_fee);
assert_eq!(
adjusted.net_value(),
net_value_without_fee.to_signed().unwrap() - target_fee.to_signed().unwrap(),
);
}
#[test]
fn test_build_funding_contribution_validates_max_money() {
let over_max = Amount::MAX_MONEY + Amount::from_sat(1);
let feerate = FeeRate::from_sat_per_kwu(2000);
{
let template = FundingTemplate::new(None, None, None, Amount::ZERO);
assert!(matches!(
template.splice_in_sync(over_max, feerate, feerate, UnreachableWallet),
Err(FundingContributionError::InvalidSpliceValue),
));
}
{
let template = FundingTemplate::new(None, None, None, Amount::ZERO);
let outputs = vec![funding_output_sats(over_max.to_sat())];
assert!(matches!(
template.splice_out(outputs, feerate, feerate),
Err(FundingContributionError::InvalidSpliceValue),
));
}
{
let template = FundingTemplate::new(None, None, None, Amount::ZERO);
let half_over = Amount::MAX_MONEY / 2 + Amount::from_sat(1);
let outputs = vec![
funding_output_sats(half_over.to_sat()),
funding_output_sats(half_over.to_sat()),
];
assert!(matches!(
template.splice_out(outputs, feerate, feerate),
Err(FundingContributionError::InvalidSpliceValue),
));
}
}
#[test]
fn test_funding_builder_validates_mixed_request_max_money() {
let over_max = Amount::MAX_MONEY + Amount::from_sat(1);
let feerate = FeeRate::from_sat_per_kwu(2000);
assert!(matches!(
FundingTemplate::new(None, None, None, Amount::ZERO)
.without_prior_contribution(feerate, feerate)
.with_coin_selection_source_sync(UnreachableWallet)
.add_value(over_max)
.unwrap()
.add_outputs(vec![funding_output_sats(1_000)])
.build(),
Err(FundingContributionError::InvalidSpliceValue),
));
let half_over = Amount::MAX_MONEY / 2 + Amount::from_sat(1);
assert!(matches!(
FundingTemplate::new(None, None, None, Amount::ZERO)
.without_prior_contribution(feerate, feerate)
.with_coin_selection_source_sync(UnreachableWallet)
.add_value(Amount::from_sat(1_000))
.unwrap()
.add_outputs(vec![
funding_output_sats(half_over.to_sat()),
funding_output_sats(half_over.to_sat()),
])
.build(),
Err(FundingContributionError::InvalidSpliceValue),
));
}
#[test]
fn test_build_funding_contribution_validates_feerate_range() {
let low = FeeRate::from_sat_per_kwu(1000);
let high = FeeRate::from_sat_per_kwu(2000);
{
let template = FundingTemplate::new(None, None, None, Amount::ZERO);
assert!(matches!(
template.splice_in_sync(Amount::from_sat(10_000), high, low, UnreachableWallet),
Err(FundingContributionError::FeeRateExceedsMaximum { .. }),
));
}
{
let template = FundingTemplate::new(None, Some(high), None, Amount::ZERO);
assert!(matches!(
template.splice_in_sync(
Amount::from_sat(10_000),
low,
FeeRate::MAX,
UnreachableWallet
),
Err(FundingContributionError::FeeRateBelowRbfMinimum { .. }),
));
}
}
#[test]
fn test_build_funding_contribution_rejects_oversized_prevtx() {
use crate::util::ser::Writeable;
let feerate = FeeRate::from_sat_per_kwu(2000);
let prevtx = Transaction {
input: vec![],
output: vec![funding_output_sats(50_000); 2_200],
version: Version::TWO,
lock_time: bitcoin::absolute::LockTime::ZERO,
};
assert!(prevtx.serialized_length() > crate::ln::LN_MAX_MSG_LEN);
let wallet = SingleUtxoWallet {
utxo: ConfirmedUtxo::new_p2wpkh(prevtx, 0).unwrap(),
change_output: None,
};
assert!(matches!(
FundingTemplate::new(None, None, None, Amount::ZERO)
.with_prior_contribution(feerate, feerate)
.with_coin_selection_source_sync(wallet)
.add_value(Amount::from_sat(10_000))
.unwrap()
.build(),
Err(FundingContributionError::PrevTxTooLarge),
));
}
#[test]
fn test_for_acceptor_at_feerate_higher_change_adjusted() {
let original_feerate = FeeRate::from_sat_per_kwu(2000);
let target_feerate = FeeRate::from_sat_per_kwu(6000);
let inputs = vec![funding_input_sats(100_000)];
let change = funding_output_sats(10_000);
let estimated_fee =
estimate_transaction_fee(&inputs, &[], Some(&change), true, true, original_feerate);
let contribution = FundingContribution {
estimated_fee,
inputs: inputs.clone(),
outputs: vec![],
change_output: Some(change.clone()),
feerate: original_feerate,
max_feerate: FeeRate::MAX,
is_splice: true,
input_mode: Some(FundingInputMode::CoinSelected),
};
let net_value_before = contribution.net_value();
let contribution =
contribution.for_acceptor_at_feerate(target_feerate, Amount::MAX_MONEY).unwrap();
let expected_target_fee =
estimate_transaction_fee(&inputs, &[], Some(&change), false, true, target_feerate);
let expected_change = estimated_fee + Amount::from_sat(10_000) - expected_target_fee;
assert_eq!(contribution.estimated_fee, expected_target_fee);
assert!(contribution.change_output.is_some());
assert_eq!(contribution.change_output.as_ref().unwrap().value, expected_change);
assert!(expected_change < Amount::from_sat(10_000)); assert_eq!(contribution.net_value(), net_value_before);
}
#[test]
fn test_for_acceptor_at_feerate_lower_rejected_too_low() {
let original_feerate = FeeRate::from_sat_per_kwu(2000);
let target_feerate = FeeRate::from_sat_per_kwu(1000);
let inputs = vec![funding_input_sats(100_000)];
let change = funding_output_sats(10_000);
let estimated_fee =
estimate_transaction_fee(&inputs, &[], Some(&change), true, true, original_feerate);
let contribution = FundingContribution {
estimated_fee,
inputs,
outputs: vec![],
change_output: Some(change),
feerate: original_feerate,
max_feerate: FeeRate::MAX,
is_splice: true,
input_mode: Some(FundingInputMode::CoinSelected),
};
let result = contribution.for_acceptor_at_feerate(target_feerate, Amount::MAX_MONEY);
assert!(matches!(result, Err(FeeRateAdjustmentError::FeeRateTooLow { .. })));
}
#[test]
fn test_for_acceptor_at_feerate_change_removed() {
let original_feerate = FeeRate::from_sat_per_kwu(2000);
let target_feerate = FeeRate::from_sat_per_kwu(7000);
let value_added = Amount::from_sat(50_000);
let change_value = Amount::from_sat(500);
let dummy_inputs = vec![funding_input_sats(1)];
let change = funding_output_sats(change_value.to_sat());
let estimated_fee = estimate_transaction_fee(
&dummy_inputs,
&[],
Some(&change),
true,
true,
original_feerate,
);
let input_value = value_added + estimated_fee + change_value;
let inputs = vec![funding_input_sats(input_value.to_sat())];
let change = funding_output_sats(change_value.to_sat());
let contribution = FundingContribution {
estimated_fee,
inputs: inputs.clone(),
outputs: vec![],
change_output: Some(change),
feerate: original_feerate,
max_feerate: FeeRate::MAX,
is_splice: true,
input_mode: Some(FundingInputMode::CoinSelected),
};
let net_value_before = contribution.net_value();
let contribution =
contribution.for_acceptor_at_feerate(target_feerate, Amount::MAX_MONEY).unwrap();
assert!(contribution.change_output.is_none());
let expected_fee_no_change =
estimate_transaction_fee(&inputs, &[], None, false, true, target_feerate);
assert_eq!(contribution.estimated_fee, expected_fee_no_change);
let surplus = estimated_fee + change_value - expected_fee_no_change;
assert_eq!(contribution.net_value(), net_value_before + surplus.to_signed().unwrap());
}
#[test]
fn test_for_acceptor_at_feerate_too_high_rejected() {
let original_feerate = FeeRate::from_sat_per_kwu(2000);
let target_feerate = FeeRate::from_sat_per_kwu(100_000);
let inputs = vec![funding_input_sats(100_000)];
let change = funding_output_sats(500);
let estimated_fee =
estimate_transaction_fee(&inputs, &[], Some(&change), true, true, original_feerate);
let contribution = FundingContribution {
estimated_fee,
inputs,
outputs: vec![],
change_output: Some(change),
feerate: original_feerate,
max_feerate: FeeRate::MAX,
is_splice: true,
input_mode: Some(FundingInputMode::CoinSelected),
};
let result = contribution.for_acceptor_at_feerate(target_feerate, Amount::MAX_MONEY);
assert!(matches!(result, Err(FeeRateAdjustmentError::FeeBufferInsufficient { .. })));
}
#[test]
fn test_for_acceptor_at_feerate_splice_out_sufficient() {
let original_feerate = FeeRate::from_sat_per_kwu(2000);
let target_feerate = FeeRate::from_sat_per_kwu(3000);
let outputs = vec![funding_output_sats(50_000)];
let estimated_fee =
estimate_transaction_fee(&[], &outputs, None, true, true, original_feerate);
let contribution = FundingContribution {
estimated_fee,
inputs: vec![],
outputs: outputs.clone(),
change_output: None,
feerate: original_feerate,
max_feerate: FeeRate::MAX,
is_splice: true,
input_mode: Some(FundingInputMode::CoinSelected),
};
let contribution =
contribution.for_acceptor_at_feerate(target_feerate, Amount::MAX_MONEY).unwrap();
let expected_target_fee =
estimate_transaction_fee(&[], &outputs, None, false, true, target_feerate);
assert_eq!(contribution.estimated_fee, expected_target_fee);
assert!(expected_target_fee <= estimated_fee);
}
#[test]
fn test_for_acceptor_at_feerate_splice_out_insufficient() {
let original_feerate = FeeRate::from_sat_per_kwu(2000);
let target_feerate = FeeRate::from_sat_per_kwu(50_000);
let outputs = vec![funding_output_sats(50_000)];
let estimated_fee =
estimate_transaction_fee(&[], &outputs, None, true, true, original_feerate);
let contribution = FundingContribution {
estimated_fee,
inputs: vec![],
outputs,
change_output: None,
feerate: original_feerate,
max_feerate: FeeRate::MAX,
is_splice: true,
input_mode: Some(FundingInputMode::CoinSelected),
};
let spliceable_balance = Amount::from_sat(55_000);
let result = contribution.for_acceptor_at_feerate(target_feerate, spliceable_balance);
assert!(matches!(result, Err(FeeRateAdjustmentError::FeeBufferInsufficient { .. })));
}
#[test]
fn test_net_value_for_acceptor_at_feerate_splice_in() {
let original_feerate = FeeRate::from_sat_per_kwu(2000);
let target_feerate = FeeRate::from_sat_per_kwu(3000);
let inputs = vec![funding_input_sats(100_000)];
let change = funding_output_sats(10_000);
let change_value = change.value;
let estimated_fee =
estimate_transaction_fee(&inputs, &[], Some(&change), true, true, original_feerate);
let contribution = FundingContribution {
estimated_fee,
inputs,
outputs: vec![],
change_output: Some(change),
feerate: original_feerate,
max_feerate: FeeRate::MAX,
is_splice: true,
input_mode: Some(FundingInputMode::CoinSelected),
};
let net_at_feerate = contribution
.net_value_for_acceptor_at_feerate(target_feerate, Amount::MAX_MONEY)
.unwrap();
assert_eq!(net_at_feerate, contribution.net_value());
assert_eq!(
net_at_feerate,
(Amount::from_sat(100_000) - estimated_fee - change_value).to_signed().unwrap(),
);
}
#[test]
fn test_net_value_for_acceptor_at_feerate_splice_out() {
let original_feerate = FeeRate::from_sat_per_kwu(2000);
let target_feerate = FeeRate::from_sat_per_kwu(3000);
let outputs = vec![funding_output_sats(50_000)];
let estimated_fee =
estimate_transaction_fee(&[], &outputs, None, true, true, original_feerate);
let contribution = FundingContribution {
estimated_fee,
inputs: vec![],
outputs: outputs.clone(),
change_output: None,
feerate: original_feerate,
max_feerate: FeeRate::MAX,
is_splice: true,
input_mode: Some(FundingInputMode::CoinSelected),
};
let net_at_feerate = contribution
.net_value_for_acceptor_at_feerate(target_feerate, Amount::MAX_MONEY)
.unwrap();
let target_fee = estimate_transaction_fee(&[], &outputs, None, false, true, target_feerate);
let expected_net = SignedAmount::ZERO
- Amount::from_sat(50_000).to_signed().unwrap()
- target_fee.to_signed().unwrap();
assert_eq!(net_at_feerate, expected_net);
assert!(net_at_feerate > contribution.net_value());
}
#[test]
fn test_net_value_for_acceptor_at_feerate_does_not_mutate() {
let original_feerate = FeeRate::from_sat_per_kwu(2000);
let target_feerate = FeeRate::from_sat_per_kwu(5000);
let inputs = vec![funding_input_sats(100_000)];
let change = funding_output_sats(10_000);
let estimated_fee =
estimate_transaction_fee(&inputs, &[], Some(&change), true, true, original_feerate);
let contribution = FundingContribution {
estimated_fee,
inputs,
outputs: vec![],
change_output: Some(change),
feerate: original_feerate,
max_feerate: FeeRate::MAX,
is_splice: true,
input_mode: Some(FundingInputMode::CoinSelected),
};
let net_before = contribution.net_value();
let fee_before = contribution.estimated_fee;
let change_before = contribution.change_output.as_ref().unwrap().value;
let _ = contribution.net_value_for_acceptor_at_feerate(target_feerate, Amount::MAX_MONEY);
assert_eq!(contribution.net_value(), net_before);
assert_eq!(contribution.estimated_fee, fee_before);
assert_eq!(contribution.change_output.as_ref().unwrap().value, change_before);
}
#[test]
fn test_net_value_for_acceptor_at_feerate_too_high() {
let original_feerate = FeeRate::from_sat_per_kwu(2000);
let target_feerate = FeeRate::from_sat_per_kwu(100_000);
let inputs = vec![funding_input_sats(100_000)];
let change = funding_output_sats(500);
let estimated_fee =
estimate_transaction_fee(&inputs, &[], Some(&change), true, true, original_feerate);
let contribution = FundingContribution {
estimated_fee,
inputs,
outputs: vec![],
change_output: Some(change),
feerate: original_feerate,
max_feerate: FeeRate::MAX,
is_splice: true,
input_mode: Some(FundingInputMode::CoinSelected),
};
let result =
contribution.net_value_for_acceptor_at_feerate(target_feerate, Amount::MAX_MONEY);
assert!(matches!(result, Err(FeeRateAdjustmentError::FeeBufferInsufficient { .. })));
}
#[test]
fn test_for_acceptor_at_feerate_exceeds_max_rejected() {
let original_feerate = FeeRate::from_sat_per_kwu(2000);
let max_feerate = FeeRate::from_sat_per_kwu(3000);
let target_feerate = FeeRate::from_sat_per_kwu(100_000);
let inputs = vec![funding_input_sats(100_000)];
let change = funding_output_sats(10_000);
let estimated_fee =
estimate_transaction_fee(&inputs, &[], Some(&change), true, true, original_feerate);
let contribution = FundingContribution {
estimated_fee,
inputs,
outputs: vec![],
change_output: Some(change),
feerate: original_feerate,
max_feerate,
is_splice: true,
input_mode: Some(FundingInputMode::CoinSelected),
};
let result = contribution.for_acceptor_at_feerate(target_feerate, Amount::MAX_MONEY);
assert!(matches!(result, Err(FeeRateAdjustmentError::FeeRateTooHigh { .. })));
}
#[test]
fn test_for_acceptor_at_feerate_exceeds_max_allowed() {
let original_feerate = FeeRate::from_sat_per_kwu(2000);
let max_feerate = FeeRate::from_sat_per_kwu(3000);
let target_feerate = FeeRate::from_sat_per_kwu(4000);
let inputs = vec![funding_input_sats(100_000)];
let change = funding_output_sats(10_000);
let estimated_fee =
estimate_transaction_fee(&inputs, &[], Some(&change), true, true, original_feerate);
let contribution = FundingContribution {
estimated_fee,
inputs,
outputs: vec![],
change_output: Some(change.clone()),
feerate: original_feerate,
max_feerate,
is_splice: true,
input_mode: Some(FundingInputMode::CoinSelected),
};
let result = contribution.for_acceptor_at_feerate(target_feerate, Amount::MAX_MONEY);
assert!(result.is_ok());
let adjusted = result.unwrap();
assert!(adjusted.change_output.is_some());
assert!(adjusted.change_output.as_ref().unwrap().value > Amount::from_sat(10_000));
}
#[test]
fn test_for_acceptor_at_feerate_within_range() {
let original_feerate = FeeRate::from_sat_per_kwu(2000);
let max_feerate = FeeRate::from_sat_per_kwu(5000);
let target_feerate = FeeRate::from_sat_per_kwu(3000);
let inputs = vec![funding_input_sats(100_000)];
let change = funding_output_sats(10_000);
let estimated_fee =
estimate_transaction_fee(&inputs, &[], Some(&change), true, true, original_feerate);
let contribution = FundingContribution {
estimated_fee,
inputs,
outputs: vec![],
change_output: Some(change),
feerate: original_feerate,
max_feerate,
is_splice: true,
input_mode: Some(FundingInputMode::CoinSelected),
};
let result = contribution.for_acceptor_at_feerate(target_feerate, Amount::MAX_MONEY);
assert!(result.is_ok());
let adjusted = result.unwrap();
assert!(adjusted.change_output.is_some());
}
#[test]
fn test_for_acceptor_at_feerate_no_change_shortfall_from_value_added() {
let original_feerate = FeeRate::from_sat_per_kwu(2000);
let target_feerate = FeeRate::from_sat_per_kwu(20_000);
let value_added = Amount::from_sat(50_000);
let dummy_inputs = vec![funding_input_sats(1)];
let estimated_fee =
estimate_transaction_fee(&dummy_inputs, &[], None, true, true, original_feerate);
let inputs = vec![funding_input_sats((value_added + estimated_fee).to_sat())];
let target_fee = estimate_transaction_fee(&inputs, &[], None, false, true, target_feerate);
assert!(target_fee > estimated_fee);
let contribution = FundingContribution {
estimated_fee,
inputs,
outputs: vec![],
change_output: None,
feerate: original_feerate,
max_feerate: FeeRate::MAX,
is_splice: true,
input_mode: Some(FundingInputMode::CoinSelected),
};
let result = contribution.for_acceptor_at_feerate(target_feerate, Amount::MAX_MONEY);
assert!(matches!(result, Err(FeeRateAdjustmentError::FeeBufferInsufficient { .. })));
}
#[test]
fn test_for_acceptor_at_feerate_no_change_insufficient() {
let original_feerate = FeeRate::from_sat_per_kwu(2000);
let target_feerate = FeeRate::from_sat_per_kwu(20_000);
let value_added = Amount::from_sat(1);
let dummy_inputs = vec![funding_input_sats(1)];
let estimated_fee =
estimate_transaction_fee(&dummy_inputs, &[], None, true, true, original_feerate);
let inputs = vec![funding_input_sats((value_added + estimated_fee).to_sat())];
let target_fee = estimate_transaction_fee(&inputs, &[], None, false, true, target_feerate);
assert!(target_fee > estimated_fee);
let contribution = FundingContribution {
estimated_fee,
inputs,
outputs: vec![],
change_output: None,
feerate: original_feerate,
max_feerate: FeeRate::MAX,
is_splice: true,
input_mode: Some(FundingInputMode::CoinSelected),
};
let result = contribution.for_acceptor_at_feerate(target_feerate, Amount::MAX_MONEY);
assert!(matches!(result, Err(FeeRateAdjustmentError::FeeBufferInsufficient { .. })));
}
#[test]
fn test_for_acceptor_at_feerate_no_change_surplus_below_dust() {
let original_feerate = FeeRate::from_sat_per_kwu(1000);
let target_feerate = FeeRate::from_sat_per_kwu(3000);
let inputs = vec![funding_input_sats(100_000)];
let estimated_fee =
estimate_transaction_fee(&inputs, &[], None, true, true, original_feerate);
let target_fee = estimate_transaction_fee(&inputs, &[], None, false, true, target_feerate);
assert!(estimated_fee > target_fee);
let dust_limit = ScriptBuf::new_p2wpkh(&WPubkeyHash::all_zeros()).minimal_non_dust();
assert!(estimated_fee - target_fee < dust_limit);
let contribution = FundingContribution {
estimated_fee,
inputs,
outputs: vec![],
change_output: None,
feerate: original_feerate,
max_feerate: FeeRate::MAX,
is_splice: true,
input_mode: Some(FundingInputMode::CoinSelected),
};
let result = contribution.for_acceptor_at_feerate(target_feerate, Amount::MAX_MONEY);
assert!(result.is_ok());
let adjusted = result.unwrap();
assert!(adjusted.change_output.is_none());
assert_eq!(adjusted.estimated_fee, target_fee);
}
#[test]
fn test_for_acceptor_at_feerate_no_change_surplus_absorbed() {
let feerate = FeeRate::from_sat_per_kwu(2000);
let value_added = Amount::from_sat(50_000);
let dummy_inputs = vec![funding_input_sats(1)];
let estimated_fee = estimate_transaction_fee(&dummy_inputs, &[], None, true, true, feerate);
let inputs = vec![funding_input_sats((value_added + estimated_fee).to_sat())];
let target_fee = estimate_transaction_fee(&inputs, &[], None, false, true, feerate);
let contribution = FundingContribution {
estimated_fee,
inputs,
outputs: vec![],
change_output: None,
feerate,
max_feerate: FeeRate::MAX,
is_splice: true,
input_mode: Some(FundingInputMode::CoinSelected),
};
let net_value_before = contribution.net_value();
let result = contribution.for_acceptor_at_feerate(feerate, Amount::MAX_MONEY);
assert!(result.is_ok());
let adjusted = result.unwrap();
assert!(adjusted.change_output.is_none());
assert_eq!(adjusted.estimated_fee, target_fee);
let surplus = estimated_fee - target_fee;
assert_eq!(adjusted.value_added(), value_added + surplus);
assert_eq!(adjusted.net_value(), net_value_before + surplus.to_signed().unwrap());
}
#[test]
fn test_for_acceptor_at_feerate_fee_buffer_overflow_with_change() {
let feerate = FeeRate::from_sat_per_kwu(2000);
let contribution = FundingContribution {
estimated_fee: Amount::MAX,
inputs: vec![funding_input_sats(100_000)],
outputs: vec![],
change_output: Some(funding_output_sats(1)),
feerate,
max_feerate: FeeRate::MAX,
is_splice: true,
input_mode: Some(FundingInputMode::CoinSelected),
};
let result = contribution.for_acceptor_at_feerate(feerate, Amount::MAX_MONEY);
assert!(matches!(result, Err(FeeRateAdjustmentError::FeeBufferOverflow)));
}
#[test]
fn test_for_acceptor_at_feerate_splice_out_balance_insufficient() {
let original_feerate = FeeRate::from_sat_per_kwu(2000);
let target_feerate = FeeRate::from_sat_per_kwu(3000);
let outputs = vec![funding_output_sats(50_000)];
let estimated_fee =
estimate_transaction_fee(&[], &outputs, None, true, true, original_feerate);
let contribution = FundingContribution {
estimated_fee,
inputs: vec![],
outputs: outputs.clone(),
change_output: None,
feerate: original_feerate,
max_feerate: FeeRate::MAX,
is_splice: true,
input_mode: Some(FundingInputMode::CoinSelected),
};
let spliceable_balance = Amount::from_sat(40_000);
let result = contribution.for_acceptor_at_feerate(target_feerate, spliceable_balance);
assert!(matches!(result, Err(FeeRateAdjustmentError::FeeBufferInsufficient { .. })));
}
#[test]
fn test_for_acceptor_at_feerate_splice_out_balance_sufficient() {
let original_feerate = FeeRate::from_sat_per_kwu(2000);
let target_feerate = FeeRate::from_sat_per_kwu(3000);
let outputs = vec![funding_output_sats(50_000)];
let estimated_fee =
estimate_transaction_fee(&[], &outputs, None, true, true, original_feerate);
let contribution = FundingContribution {
estimated_fee,
inputs: vec![],
outputs: outputs.clone(),
change_output: None,
feerate: original_feerate,
max_feerate: FeeRate::MAX,
is_splice: true,
input_mode: Some(FundingInputMode::CoinSelected),
};
let spliceable_balance = Amount::from_sat(100_000);
let contribution =
contribution.for_acceptor_at_feerate(target_feerate, spliceable_balance).unwrap();
let expected_target_fee =
estimate_transaction_fee(&[], &outputs, None, false, true, target_feerate);
assert_eq!(contribution.estimated_fee, expected_target_fee);
}
#[test]
fn test_net_value_for_acceptor_at_feerate_splice_out_balance_insufficient() {
let original_feerate = FeeRate::from_sat_per_kwu(2000);
let target_feerate = FeeRate::from_sat_per_kwu(3000);
let outputs = vec![funding_output_sats(50_000)];
let estimated_fee =
estimate_transaction_fee(&[], &outputs, None, true, true, original_feerate);
let contribution = FundingContribution {
estimated_fee,
inputs: vec![],
outputs,
change_output: None,
feerate: original_feerate,
max_feerate: FeeRate::MAX,
is_splice: true,
input_mode: Some(FundingInputMode::CoinSelected),
};
let spliceable_balance = Amount::from_sat(40_000);
let result =
contribution.net_value_for_acceptor_at_feerate(target_feerate, spliceable_balance);
assert!(matches!(result, Err(FeeRateAdjustmentError::FeeBufferInsufficient { .. })));
}
#[test]
fn test_for_initiator_at_feerate_higher_fee_than_acceptor() {
let original_feerate = FeeRate::from_sat_per_kwu(2000);
let target_feerate = FeeRate::from_sat_per_kwu(3000);
let inputs = vec![funding_input_sats(100_000)];
let change = funding_output_sats(10_000);
let estimated_fee =
estimate_transaction_fee(&inputs, &[], Some(&change), true, true, original_feerate);
let contribution = FundingContribution {
estimated_fee,
inputs,
outputs: vec![],
change_output: Some(change),
feerate: original_feerate,
max_feerate: FeeRate::MAX,
is_splice: true,
input_mode: Some(FundingInputMode::CoinSelected),
};
let acceptor = contribution
.clone()
.for_acceptor_at_feerate(target_feerate, Amount::MAX_MONEY)
.unwrap();
let initiator =
contribution.for_initiator_at_feerate(target_feerate, Amount::MAX_MONEY).unwrap();
assert!(initiator.estimated_fee > acceptor.estimated_fee);
assert!(
initiator.change_output.as_ref().unwrap().value
< acceptor.change_output.as_ref().unwrap().value
);
assert_eq!(initiator.feerate, target_feerate);
assert_eq!(acceptor.feerate, target_feerate);
}
#[test]
fn test_rbf_rejects_max_feerate_below_min_rbf_feerate() {
let prior_feerate = FeeRate::from_sat_per_kwu(2000);
let min_rbf_feerate = FeeRate::from_sat_per_kwu(2025);
let max_feerate = FeeRate::from_sat_per_kwu(2020);
let prior = FundingContribution {
estimated_fee: Amount::from_sat(1_000),
inputs: vec![funding_input_sats(100_000)],
outputs: vec![],
change_output: None,
feerate: prior_feerate,
max_feerate: FeeRate::MAX,
is_splice: true,
input_mode: Some(FundingInputMode::CoinSelected),
};
let template =
FundingTemplate::new(None, Some(min_rbf_feerate), Some(prior), Amount::MAX_MONEY);
assert!(matches!(
template.rbf_prior_contribution_sync(None, max_feerate, UnreachableWallet),
Err(FundingContributionError::FeeRateExceedsMaximum { .. }),
));
}
#[test]
fn test_rbf_adjusts_prior_to_rbf_feerate() {
let prior_feerate = FeeRate::from_sat_per_kwu(2000);
let min_rbf_feerate = FeeRate::from_sat_per_kwu(2025);
let max_feerate = FeeRate::from_sat_per_kwu(5000);
let inputs = vec![funding_input_sats(100_000)];
let change = funding_output_sats(10_000);
let estimated_fee =
estimate_transaction_fee(&inputs, &[], Some(&change), true, true, prior_feerate);
let prior = FundingContribution {
estimated_fee,
inputs,
outputs: vec![],
change_output: Some(change),
feerate: prior_feerate,
max_feerate: FeeRate::MAX,
is_splice: true,
input_mode: Some(FundingInputMode::CoinSelected),
};
let template =
FundingTemplate::new(None, Some(min_rbf_feerate), Some(prior), Amount::MAX_MONEY);
let contribution =
template.rbf_prior_contribution_sync(None, max_feerate, UnreachableWallet).unwrap();
assert_eq!(contribution.feerate, min_rbf_feerate);
assert_eq!(contribution.max_feerate, max_feerate);
}
#[test]
fn test_rbf_uses_explicit_override_feerate() {
let prior_feerate = FeeRate::from_sat_per_kwu(2000);
let min_rbf_feerate = FeeRate::from_sat_per_kwu(2025);
let override_feerate = FeeRate::from_sat_per_kwu(2100);
let max_feerate = FeeRate::from_sat_per_kwu(5000);
let inputs = vec![funding_input_sats(100_000)];
let change = funding_output_sats(10_000);
let estimated_fee =
estimate_transaction_fee(&inputs, &[], Some(&change), true, true, prior_feerate);
let prior = FundingContribution {
estimated_fee,
inputs,
outputs: vec![],
change_output: Some(change),
feerate: prior_feerate,
max_feerate: FeeRate::MAX,
is_splice: true,
input_mode: Some(FundingInputMode::CoinSelected),
};
let template =
FundingTemplate::new(None, Some(min_rbf_feerate), Some(prior), Amount::MAX_MONEY);
let contribution = template
.rbf_prior_contribution_sync(Some(override_feerate), max_feerate, UnreachableWallet)
.unwrap();
assert_eq!(contribution.feerate, override_feerate);
assert_eq!(contribution.max_feerate, max_feerate);
}
#[test]
fn test_rbf_rejects_explicit_override_below_min_rbf_feerate() {
let prior_feerate = FeeRate::from_sat_per_kwu(2000);
let min_rbf_feerate = FeeRate::from_sat_per_kwu(2025);
let override_feerate = FeeRate::from_sat_per_kwu(2024);
let prior = FundingContribution {
estimated_fee: Amount::from_sat(1_000),
inputs: vec![funding_input_sats(100_000)],
outputs: vec![],
change_output: None,
feerate: prior_feerate,
max_feerate: FeeRate::MAX,
is_splice: true,
input_mode: Some(FundingInputMode::CoinSelected),
};
let template =
FundingTemplate::new(None, Some(min_rbf_feerate), Some(prior), Amount::MAX_MONEY);
assert!(matches!(
template.rbf_prior_contribution_sync(
Some(override_feerate),
FeeRate::MAX,
UnreachableWallet,
),
Err(FundingContributionError::FeeRateBelowRbfMinimum { .. }),
));
}
#[test]
fn test_rbf_rejects_explicit_override_above_max_feerate() {
let prior_feerate = FeeRate::from_sat_per_kwu(2000);
let min_rbf_feerate = FeeRate::from_sat_per_kwu(2025);
let override_feerate = FeeRate::from_sat_per_kwu(2100);
let max_feerate = FeeRate::from_sat_per_kwu(2099);
let prior = FundingContribution {
estimated_fee: Amount::from_sat(1_000),
inputs: vec![funding_input_sats(100_000)],
outputs: vec![],
change_output: None,
feerate: prior_feerate,
max_feerate: FeeRate::MAX,
is_splice: true,
input_mode: Some(FundingInputMode::CoinSelected),
};
let template =
FundingTemplate::new(None, Some(min_rbf_feerate), Some(prior), Amount::MAX_MONEY);
assert!(matches!(
template.rbf_prior_contribution_sync(
Some(override_feerate),
max_feerate,
UnreachableWallet,
),
Err(FundingContributionError::FeeRateExceedsMaximum { .. }),
));
}
struct SingleUtxoWallet {
utxo: ConfirmedUtxo,
change_output: Option<TxOut>,
}
impl CoinSelectionSourceSync for SingleUtxoWallet {
fn select_confirmed_utxos(
&self, _claim_id: Option<ClaimId>, _must_spend: Vec<Input>, _must_pay_to: &[TxOut],
_target_feerate_sat_per_1000_weight: u32, _max_tx_weight: u64,
) -> Result<CoinSelection, ()> {
Ok(CoinSelection {
confirmed_utxos: vec![self.utxo.clone()],
change_output: self.change_output.clone(),
})
}
fn sign_psbt(&self, _psbt: Psbt) -> Result<Transaction, ()> {
unreachable!("should not reach signing")
}
}
fn shared_input(value_sats: u64) -> Input {
Input {
outpoint: bitcoin::OutPoint::null(),
previous_utxo: TxOut {
value: Amount::from_sat(value_sats),
script_pubkey: ScriptBuf::new_p2wpkh(&WPubkeyHash::all_zeros()),
},
satisfaction_weight: 107,
}
}
#[test]
fn test_rbf_unadjusted_splice_out_runs_coin_selection() {
let prior_feerate = FeeRate::from_sat_per_kwu(2000);
let min_rbf_feerate = FeeRate::from_sat_per_kwu(2025);
let withdrawal = funding_output_sats(20_000);
let prior = FundingContribution {
estimated_fee: Amount::from_sat(500),
inputs: vec![],
outputs: vec![withdrawal.clone()],
change_output: None,
feerate: prior_feerate,
max_feerate: prior_feerate,
is_splice: true,
input_mode: Some(FundingInputMode::CoinSelected),
};
let template = FundingTemplate::new(
Some(shared_input(100_000)),
Some(min_rbf_feerate),
Some(prior),
Amount::ZERO,
);
let wallet = SingleUtxoWallet {
utxo: funding_input_sats(50_000),
change_output: Some(funding_output_sats(25_000)),
};
let contribution =
template.rbf_prior_contribution_sync(None, FeeRate::MAX, &wallet).unwrap();
assert!(!contribution.inputs.is_empty(), "coin selection should have added inputs");
assert!(contribution.value_added() > Amount::ZERO);
assert_eq!(contribution.outputs, vec![withdrawal]);
assert_eq!(contribution.feerate, min_rbf_feerate);
}
#[test]
fn test_rbf_unadjusted_uses_callers_max_feerate() {
let min_rbf_feerate = FeeRate::from_sat_per_kwu(2025);
let prior_max_feerate = FeeRate::from_sat_per_kwu(50_000);
let callers_max_feerate = FeeRate::from_sat_per_kwu(10_000);
let withdrawal = funding_output_sats(20_000);
let prior = FundingContribution {
estimated_fee: Amount::from_sat(500),
inputs: vec![],
outputs: vec![withdrawal.clone()],
change_output: None,
feerate: FeeRate::from_sat_per_kwu(2000),
max_feerate: prior_max_feerate,
is_splice: true,
input_mode: Some(FundingInputMode::CoinSelected),
};
let template = FundingTemplate::new(
Some(shared_input(100_000)),
Some(min_rbf_feerate),
Some(prior),
Amount::MAX_MONEY,
);
let wallet = SingleUtxoWallet {
utxo: funding_input_sats(50_000),
change_output: Some(funding_output_sats(25_000)),
};
let contribution =
template.rbf_prior_contribution_sync(None, callers_max_feerate, &wallet).unwrap();
assert_eq!(
contribution.max_feerate, callers_max_feerate,
"should use caller's max_feerate, not prior's"
);
}
#[test]
fn test_splice_out_skips_coin_selection_during_rbf() {
let min_rbf_feerate = FeeRate::from_sat_per_kwu(2025);
let feerate = FeeRate::from_sat_per_kwu(2025);
let withdrawal = funding_output_sats(20_000);
let template = FundingTemplate::new(
Some(shared_input(100_000)),
Some(min_rbf_feerate),
None,
Amount::MAX_MONEY,
);
let contribution =
template.splice_out(vec![withdrawal.clone()], feerate, FeeRate::MAX).unwrap();
assert_eq!(contribution.value_added(), Amount::ZERO);
assert!(contribution.inputs.is_empty());
assert!(contribution.change_output.is_none());
assert_eq!(contribution.outputs, vec![withdrawal]);
}
}