Merge #352: Transaction creation sanity checks fixes

518eb91064ac4120792b71cec8f52eb26e359b89 commands: print the PSBT in base64 on sanity checks failure (Antoine Poinsot)
6e1c41238faaca0b6cf7e4131885ec2588d2137c commands: have a detailed error for the insane fee sanity check (Antoine Poinsot)
c711c5b696b7c8353da29b3c451bf2ca00e8daa8 commands: don't overestimate the feerate in tx creation sanity checks (Antoine Poinsot)

Pull request description:

  Fixes #343

ACKs for top commit:
  edouardparis:
    utACK 518eb91064ac4120792b71cec8f52eb26e359b89

Tree-SHA512: 99ec10c5e7aa050fcb2b0ac6f0d546e14b2f00e75430ebdaceff78758880a7a8336bda84692738599b7a0f1d56a92db75321e4d2f4e218a42ac920e7dd8503a5
This commit is contained in:
Antoine Poinsot 2023-02-17 11:02:18 +01:00
commit eb6521b096
No known key found for this signature in database
GPG Key ID: E13FC145CD3F4304
3 changed files with 78 additions and 12 deletions

View File

@ -10,7 +10,10 @@ use crate::{
descriptors, DaemonControl, VERSION, descriptors, DaemonControl, VERSION,
}; };
use utils::{deser_amount_from_sats, deser_base64, deser_hex, ser_amount, ser_base64, ser_hex}; use utils::{
deser_amount_from_sats, deser_base64, deser_hex, ser_amount, ser_base64, ser_hex,
to_base64_string,
};
use std::{ use std::{
collections::{hash_map, BTreeMap, HashMap}, collections::{hash_map, BTreeMap, HashMap},
@ -54,6 +57,7 @@ pub enum CommandError {
/* out value */ bitcoin::Amount, /* out value */ bitcoin::Amount,
/* target feerate */ u64, /* target feerate */ u64,
), ),
InsaneFees(InsaneFeeInfo),
FetchingTransaction(bitcoin::OutPoint), FetchingTransaction(bitcoin::OutPoint),
SanityCheckFailure(Psbt), SanityCheckFailure(Psbt),
UnknownSpend(bitcoin::Txid), UnknownSpend(bitcoin::Txid),
@ -86,13 +90,26 @@ impl fmt::Display for CommandError {
"Cannot create a {} sat/vb transaction with input value {} and output value {}", "Cannot create a {} sat/vb transaction with input value {} and output value {}",
feerate, in_val, out_val feerate, in_val, out_val
), ),
Self::InsaneFees(info) => write!(
f,
"We assume transactions with a fee larger than {} sats or a feerate larger than {} sats/vb are a mistake. \
The created transaction {}.",
MAX_FEE,
MAX_FEERATE,
match info {
InsaneFeeInfo::NegativeFee => "would have a negative fee".to_string(),
InsaneFeeInfo::TooHighFee(f) => format!("{} sats in fees", f),
InsaneFeeInfo::InvalidFeerate => "would have an invalid feerate".to_string(),
InsaneFeeInfo::TooHighFeerate(r) => format!("has a feerate of {} sats/vb", r),
},
),
Self::FetchingTransaction(op) => { Self::FetchingTransaction(op) => {
write!(f, "Could not fetch transaction for coin {}", op) write!(f, "Could not fetch transaction for coin {}", op)
} }
Self::SanityCheckFailure(psbt) => write!( Self::SanityCheckFailure(psbt) => write!(
f, f,
"BUG! Please report this. Failed sanity checks for PSBT '{:?}'.", "BUG! Please report this. Failed sanity checks for PSBT '{}'.",
psbt to_base64_string(psbt)
), ),
Self::UnknownSpend(txid) => write!(f, "Unknown spend transaction '{}'.", txid), Self::UnknownSpend(txid) => write!(f, "Unknown spend transaction '{}'.", txid),
Self::SpendFinalization(e) => { Self::SpendFinalization(e) => {
@ -127,9 +144,20 @@ fn check_output_value(value: bitcoin::Amount) -> Result<(), CommandError> {
} }
} }
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum InsaneFeeInfo {
NegativeFee,
InvalidFeerate,
TooHighFee(u64),
TooHighFeerate(u64),
}
// Apply some sanity checks on a created transaction's PSBT. // Apply some sanity checks on a created transaction's PSBT.
// TODO: add more sanity checks from revault_tx // TODO: add more sanity checks from revault_tx
fn sanity_check_psbt(psbt: &Psbt) -> Result<(), CommandError> { fn sanity_check_psbt(
spent_desc: &descriptors::MultipathDescriptor,
psbt: &Psbt,
) -> Result<(), CommandError> {
let tx = &psbt.unsigned_tx; let tx = &psbt.unsigned_tx;
// Must have as many in/out in the PSBT and Bitcoin tx. // Must have as many in/out in the PSBT and Bitcoin tx.
@ -155,18 +183,20 @@ fn sanity_check_psbt(psbt: &Psbt) -> Result<(), CommandError> {
let value_out: u64 = tx.output.iter().map(|o| o.value).sum(); let value_out: u64 = tx.output.iter().map(|o| o.value).sum();
let abs_fee = value_in let abs_fee = value_in
.checked_sub(value_out) .checked_sub(value_out)
.ok_or_else(|| CommandError::SanityCheckFailure(psbt.clone()))?; .ok_or_else(|| CommandError::InsaneFees(InsaneFeeInfo::NegativeFee))?;
if abs_fee > MAX_FEE { if abs_fee > MAX_FEE {
return Err(CommandError::SanityCheckFailure(psbt.clone())); return Err(CommandError::InsaneFees(InsaneFeeInfo::TooHighFee(abs_fee)));
} }
// Check the feerate isn't insane. // Check the feerate isn't insane.
let tx_vb = tx.vsize() as u64; let tx_vb = (tx.vsize() + spent_desc.max_sat_vbytes() * tx.input.len()) as u64;
let feerate_sats_vb = abs_fee let feerate_sats_vb = abs_fee
.checked_div(tx_vb) .checked_div(tx_vb)
.ok_or_else(|| CommandError::SanityCheckFailure(psbt.clone()))?; .ok_or_else(|| CommandError::InsaneFees(InsaneFeeInfo::InvalidFeerate))?;
if !(1..=MAX_FEERATE).contains(&feerate_sats_vb) { if !(1..=MAX_FEERATE).contains(&feerate_sats_vb) {
return Err(CommandError::SanityCheckFailure(psbt.clone())); return Err(CommandError::InsaneFees(InsaneFeeInfo::TooHighFeerate(
feerate_sats_vb,
)));
} }
// Check for dust outputs // Check for dust outputs
@ -439,6 +469,9 @@ impl DaemonControl {
let with_change_feerate_vb = absolute_fee.to_sat().checked_div(with_change_vb).unwrap(); let with_change_feerate_vb = absolute_fee.to_sat().checked_div(with_change_vb).unwrap();
if with_change_feerate_vb > feerate_vb { if with_change_feerate_vb > feerate_vb {
// TODO: try first with the exact feerate, then try again with 90% of the feerate
// if it fails. Otherwise with small transactions and large feerates it's possible
// the feerate increase from the target be dramatically higher.
let target_fee = with_change_vb.checked_mul(feerate_vb).unwrap(); let target_fee = with_change_vb.checked_mul(feerate_vb).unwrap();
let change_amount = absolute_fee let change_amount = absolute_fee
.checked_sub(bitcoin::Amount::from_sat(target_fee)) .checked_sub(bitcoin::Amount::from_sat(target_fee))
@ -466,7 +499,7 @@ impl DaemonControl {
inputs: psbt_ins, inputs: psbt_ins,
outputs: psbt_outs, outputs: psbt_outs,
}; };
sanity_check_psbt(&psbt)?; sanity_check_psbt(&self.config.main_descriptor, &psbt)?;
// TODO: maybe check for common standardness rules (max size, ..)? // TODO: maybe check for common standardness rules (max size, ..)?
Ok(CreateSpendResult { psbt }) Ok(CreateSpendResult { psbt })
@ -742,7 +775,7 @@ impl DaemonControl {
})?; })?;
psbt.unsigned_tx.output[0].value = output_value.to_sat(); psbt.unsigned_tx.output[0].value = output_value.to_sat();
sanity_check_psbt(&psbt)?; sanity_check_psbt(&self.config.main_descriptor, &psbt)?;
Ok(CreateRecoveryResult { psbt }) Ok(CreateRecoveryResult { psbt })
} }
@ -950,6 +983,12 @@ mod tests {
let tx = res.psbt.unsigned_tx; let tx = res.psbt.unsigned_tx;
assert_eq!(tx.output[1].value, 89_658); assert_eq!(tx.output[1].value, 89_658);
// A feerate of 555 won't trigger the sanity checks (they were previously not taking the
// satisfaction size into account and overestimating the feerate).
control
.create_spend(&destinations, &[dummy_op], 555)
.unwrap();
// If we ask for a too high feerate, or a too large/too small output, it'll fail. // If we ask for a too high feerate, or a too large/too small output, it'll fail.
assert_eq!( assert_eq!(
control.create_spend(&destinations, &[dummy_op], 10_000), control.create_spend(&destinations, &[dummy_op], 10_000),
@ -1019,6 +1058,28 @@ mod tests {
Err(CommandError::AlreadySpent(dummy_op)) Err(CommandError::AlreadySpent(dummy_op))
); );
// We'd bail out if they tried to create a transaction with a too high feerate.
let dummy_op_dup = bitcoin::OutPoint {
txid: dummy_op.txid,
vout: dummy_op.vout + 10,
};
db_conn.new_unspent_coins(&[Coin {
outpoint: dummy_op_dup,
block_height: None,
block_time: None,
amount: bitcoin::Amount::from_sat(400_000),
derivation_index: bip32::ChildNumber::from(42),
is_change: false,
spend_txid: None,
spend_block: None,
}]);
assert_eq!(
control.create_spend(&destinations, &[dummy_op_dup], 1_001),
Err(CommandError::InsaneFees(InsaneFeeInfo::TooHighFeerate(
1001
)))
);
ms.shutdown(); ms.shutdown();
} }

View File

@ -15,12 +15,16 @@ where
Ok(bitcoin::Amount::from_sat(a)) Ok(bitcoin::Amount::from_sat(a))
} }
pub fn to_base64_string<T: consensus::Encodable>(t: T) -> String {
base64::encode(consensus::serialize(&t))
}
pub fn ser_base64<S, T>(t: T, s: S) -> Result<S::Ok, S::Error> pub fn ser_base64<S, T>(t: T, s: S) -> Result<S::Ok, S::Error>
where where
S: Serializer, S: Serializer,
T: consensus::Encodable, T: consensus::Encodable,
{ {
s.serialize_str(&base64::encode(consensus::serialize(&t))) s.serialize_str(&to_base64_string(t))
} }
pub fn deser_base64<'de, D, T>(d: D) -> Result<T, D::Error> pub fn deser_base64<'de, D, T>(d: D) -> Result<T, D::Error>

View File

@ -159,6 +159,7 @@ impl From<commands::CommandError> for Error {
| commands::CommandError::AddressNetwork(..) | commands::CommandError::AddressNetwork(..)
| commands::CommandError::InvalidOutputValue(..) | commands::CommandError::InvalidOutputValue(..)
| commands::CommandError::InsufficientFunds(..) | commands::CommandError::InsufficientFunds(..)
| commands::CommandError::InsaneFees(..)
| commands::CommandError::UnknownSpend(..) | commands::CommandError::UnknownSpend(..)
| commands::CommandError::SpendFinalization(..) | commands::CommandError::SpendFinalization(..)
| commands::CommandError::InsaneRescanTimestamp(..) | commands::CommandError::InsaneRescanTimestamp(..)