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:
commit
eb6521b096
@ -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();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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(..)
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user