Skip to content
New issue

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

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

Already on GitHub? Sign in to your account

spend: warn when overpaying fee #905

Merged
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions doc/API.md
Original file line number Diff line number Diff line change
Expand Up @@ -179,9 +179,10 @@ This command will refuse to create any output worth less than 5k sats.

#### Response

| Field | Type | Description |
| -------------- | --------- | ---------------------------------------------------- |
| `psbt` | string | PSBT of the spending transaction, encoded as base64. |
| Field | Type | Description |
| -------------- | ----------------- | ---------------------------------------------------- |
| `psbt` | string | PSBT of the spending transaction, encoded as base64. |
| `warnings` | list of string | Warnings, if any, generated during spend creation. |


### `updatespend`
Expand Down
84 changes: 80 additions & 4 deletions src/commands/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -490,7 +490,11 @@ impl DaemonControl {
// derivation index in case any address in the transaction outputs was ours and from the
// future.
let change_info = change_address.info;
let CreateSpendRes { psbt, has_change } = create_spend(
let CreateSpendRes {
psbt,
has_change,
warnings,
} = create_spend(
&self.config.main_descriptor,
&self.secp,
&mut tx_getter,
Expand All @@ -506,7 +510,10 @@ impl DaemonControl {
self.maybe_increase_next_deriv_index(&mut db_conn, &change_info);
}

Ok(CreateSpendResult { psbt })
Ok(CreateSpendResult {
psbt,
warnings: warnings.iter().map(|w| w.to_string()).collect(),
})
}

pub fn update_spend(&self, mut psbt: Psbt) -> Result<(), CommandError> {
Expand Down Expand Up @@ -810,6 +817,7 @@ impl DaemonControl {
let CreateSpendRes {
psbt: rbf_psbt,
has_change,
warnings,
} = match create_spend(
&self.config.main_descriptor,
&self.secp,
Expand Down Expand Up @@ -854,7 +862,10 @@ impl DaemonControl {
self.maybe_increase_next_deriv_index(&mut db_conn, &change_address.info);
}

return Ok(CreateSpendResult { psbt: rbf_psbt });
return Ok(CreateSpendResult {
psbt: rbf_psbt,
warnings: warnings.iter().map(|w| w.to_string()).collect(),
});
}
}

Expand Down Expand Up @@ -985,7 +996,9 @@ impl DaemonControl {
}

let sweep_addr_info = sweep_addr.info;
let CreateSpendRes { psbt, has_change } = create_spend(
let CreateSpendRes {
psbt, has_change, ..
} = create_spend(
&self.config.main_descriptor,
&self.secp,
&mut tx_getter,
Expand Down Expand Up @@ -1098,6 +1111,7 @@ pub struct ListCoinsResult {
pub struct CreateSpendResult {
#[serde(serialize_with = "ser_to_string", deserialize_with = "deser_fromstr")]
pub psbt: Psbt,
pub warnings: Vec<String>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
Expand Down Expand Up @@ -1136,6 +1150,7 @@ mod tests {
use super::*;
use crate::{bitcoin::Block, database::BlockInfo, spend::InsaneFeeInfo, testutils::*};

use bdk_coin_select::InsufficientFunds;
use bitcoin::{
bip32::{self, ChildNumber},
blockdata::transaction::{TxIn, TxOut, Version as TxVersion},
Expand Down Expand Up @@ -1369,6 +1384,8 @@ mod tests {
assert_eq!(tx.input.len(), 1);
assert_eq!(tx.input[0].previous_output, dummy_op);
assert_eq!(tx.output.len(), 2);
// It has change so no warnings expected.
assert!(res.warnings.is_empty());
assert_eq!(
tx.output[0].script_pubkey,
dummy_addr.payload().script_pubkey()
Expand Down Expand Up @@ -1445,6 +1462,65 @@ mod tests {
dummy_addr.payload().script_pubkey()
);
assert_eq!(tx.output[0].value.to_sat(), 95_000);
// change = 100_000 - 95_000 - /* fee without change */ 127 - /* extra fee for change output */ 43 = 4830
assert_eq!(res.warnings, vec!["Change amount of 4830 sats added to fee as it was too small to create a transaction output."]);

// Increase the target value by the change amount and the warning will disappear.
*destinations.get_mut(&dummy_addr).unwrap() = 95_000 + 4_830;
let res = control
.create_spend(&destinations, &[dummy_op], 1, None)
.unwrap();
let tx = res.psbt.unsigned_tx;
assert_eq!(tx.output.len(), 1);
assert!(res.warnings.is_empty());

// Now increase target also by the extra fee that was paying for change and we can still create the spend.
*destinations.get_mut(&dummy_addr).unwrap() =
95_000 + 4_830 + /* fee for change output */ 43;
let res = control
.create_spend(&destinations, &[dummy_op], 1, None)
.unwrap();
let tx = res.psbt.unsigned_tx;
assert_eq!(tx.output.len(), 1);
assert!(res.warnings.is_empty());

// Now increase the target by 1 more sat and we will have insufficient funds.
*destinations.get_mut(&dummy_addr).unwrap() =
95_000 + 4_830 + /* fee for change output */ 43 + 1;
assert_eq!(
control.create_spend(&destinations, &[dummy_op], 1, None),
Err(CommandError::SpendCreation(
SpendCreationError::CoinSelection(InsufficientFunds { missing: 1 })
))
);

// Now decrease the target so that the lost change is just 1 sat.
*destinations.get_mut(&dummy_addr).unwrap() =
100_000 - /* fee without change */ 127 - /* extra fee for change output */ 43 - 1;
let res = control
.create_spend(&destinations, &[dummy_op], 1, None)
.unwrap();
// Message uses "sat" instead of "sats" when value is 1.
assert_eq!(res.warnings, vec!["Change amount of 1 sat added to fee as it was too small to create a transaction output."]);

// Now decrease the target value so that we have enough for a change output.
*destinations.get_mut(&dummy_addr).unwrap() =
95_000 - /* fee without change */ 127 - /* extra fee for change output */ 43;
let res = control
.create_spend(&destinations, &[dummy_op], 1, None)
.unwrap();
let tx = res.psbt.unsigned_tx;
assert_eq!(tx.output.len(), 2);
assert_eq!(tx.output[1].value.to_sat(), 5_000);
assert!(res.warnings.is_empty());

// Now increase the target by 1 and we'll get a warning again, this time for 1 less than the dust threshold.
*destinations.get_mut(&dummy_addr).unwrap() =
95_000 - /* fee without change */ 127 - /* extra fee for change output */ 43 + 1;
let res = control
.create_spend(&destinations, &[dummy_op], 1, None)
.unwrap();
assert_eq!(res.warnings, vec!["Change amount of 4999 sats added to fee as it was too small to create a transaction output."]);

// Now if we mark the coin as spent, we won't create another Spend transaction containing
// it.
Expand Down
78 changes: 70 additions & 8 deletions src/spend.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ use miniscript::bitcoin::{
psbt::{Input as PsbtIn, Output as PsbtOut, Psbt},
secp256k1,
};
use serde::{Deserialize, Serialize};

/// We would never create a transaction with an output worth less than this.
/// That's 1$ at 20_000$ per BTC.
Expand Down Expand Up @@ -170,6 +171,21 @@ pub struct CandidateCoin {
pub sequence: Option<bitcoin::Sequence>,
}

/// A coin selection result.
///
/// A change output should only be added if `change_amount > 0`.
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct CoinSelectionRes {
/// Selected candidates.
pub selected: Vec<CandidateCoin>,
/// Change amount that should be included according to the change policy used
/// for selection.
pub change_amount: bitcoin::Amount,
/// Maximum change amount possible with the selection irrespective of any change
/// policy.
pub max_change_amount: bitcoin::Amount,
}

/// Metric based on [`LowestFee`] that aims to minimize transaction fees
/// with the additional option to only find solutions with a change output.
///
Expand Down Expand Up @@ -254,7 +270,7 @@ fn select_coins_for_spend(
min_fee: u64,
max_sat_weight: u32,
must_have_change: bool,
) -> Result<(Vec<CandidateCoin>, bitcoin::Amount), InsufficientFunds> {
) -> Result<CoinSelectionRes, InsufficientFunds> {
let out_value_nochange = base_tx.output.iter().map(|o| o.value.to_sat()).sum();

// Create the coin selector from the given candidates. NOTE: the coin selector keeps track
Expand Down Expand Up @@ -344,7 +360,7 @@ fn select_coins_for_spend(
#[cfg(debug)]
let bnb_rounds = bnb_rounds / 1_000;
if let Err(e) = selector.run_bnb(lowest_fee_change_cond, bnb_rounds) {
log::warn!(
log::debug!(
"Coin selection error: '{}'. Selecting coins by descending value per weight unit...",
e.to_string()
);
Expand Down Expand Up @@ -374,14 +390,27 @@ fn select_coins_for_spend(
// By now, selection is complete and we can check how much change to give according to our policy.
let drain = change_policy(&selector, target);
let change_amount = bitcoin::Amount::from_sat(drain.value);
Ok((
// Max available change is given by the excess when adding a change output with zero value.
let drain_novalue = bdk_coin_select::Drain {
weights: drain_weights,
value: 0,
};
let max_change_amount = bitcoin::Amount::from_sat(
selector
.excess(target, drain_novalue)
.max(0) // negative excess would mean insufficient funds to pay for change output
Copy link
Member

@darosior darosior Jan 13, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So this can only happen if we've got a selection without change such as adding a change output would decrease the feerate below the target? Any other reason for a negative excess here would be a logic error because at this point we do indeed have enough funds?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's right, the selected coins here have enough value without a change output and so the negative excess would mean that the selected coins don't have enough value to pay the extra weight for the change output at the target feerate.

.try_into()
.expect("value is non-negative"),
);
Ok(CoinSelectionRes {
selected: selector
.selected_indices()
.iter()
.map(|i| candidate_coins[*i])
.collect(),
change_amount,
))
max_change_amount,
})
}

// Get the derived descriptor for this coin
Expand Down Expand Up @@ -427,11 +456,31 @@ pub enum SpendTxFees {
Rbf(u64, u64),
}

#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub enum CreateSpendWarning {
ChangeAddedToFee(u64),
}

impl fmt::Display for CreateSpendWarning {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
match self {
CreateSpendWarning::ChangeAddedToFee(amt) => write!(
f,
"Change amount of {} sat{} added to fee as it was too small to create a transaction output.",
amt,
if *amt > 1 {"s"} else {""},
),
}
}
}

pub struct CreateSpendRes {
/// The created PSBT.
pub psbt: Psbt,
/// Whether the created PSBT has a change output.
pub has_change: bool,
/// Warnings relating to the PSBT.
pub warnings: Vec<CreateSpendWarning>,
}

/// Create a PSBT for a transaction spending some, or all, of `candidate_coins` to `destinations`.
Expand Down Expand Up @@ -480,6 +529,7 @@ pub fn create_spend(
// 3. Add the selected coins as inputs to the transaction.
// 4. Finalize the PSBT and sanity check it before returning it.

let mut warnings = Vec::new();
let (feerate_vb, min_fee) = match fees {
SpendTxFees::Regular(feerate) => (feerate, 0),
SpendTxFees::Rbf(feerate, fee) => (feerate, fee),
Expand Down Expand Up @@ -535,7 +585,11 @@ pub fn create_spend(
};
// Now select the coins necessary using the provided candidates and determine whether
// there is any leftover to create a change output.
let (selected_coins, change_amount) = {
let CoinSelectionRes {
selected,
change_amount,
max_change_amount,
} = {
// At this point the transaction still has no input and no change output, as expected
// by the coins selection helper function.
assert!(tx.input.is_empty());
Expand Down Expand Up @@ -590,11 +644,15 @@ pub fn create_spend(
bip32_derivation,
..PsbtOut::default()
});
} else if max_change_amount.to_sat() > 0 {
warnings.push(CreateSpendWarning::ChangeAddedToFee(
max_change_amount.to_sat(),
));
}

// Iterate through selected coins and add necessary information to the PSBT inputs.
let mut psbt_ins = Vec::with_capacity(selected_coins.len());
for cand in &selected_coins {
let mut psbt_ins = Vec::with_capacity(selected.len());
for cand in &selected {
let sequence = cand
.sequence
.unwrap_or(bitcoin::Sequence::ENABLE_RBF_NO_LOCKTIME);
Expand Down Expand Up @@ -636,5 +694,9 @@ pub fn create_spend(
sanity_check_psbt(main_descriptor, &psbt)?;
// TODO: maybe check for common standardness rules (max size, ..)?

Ok(CreateSpendRes { psbt, has_change })
Ok(CreateSpendRes {
psbt,
has_change,
warnings,
})
}
Loading
Loading