Skip to content

Commit

Permalink
Feature: Psbt fee checks
Browse files Browse the repository at this point in the history
  • Loading branch information
junderw committed Sep 12, 2023
1 parent 26c27d9 commit 63f500e
Show file tree
Hide file tree
Showing 4 changed files with 190 additions and 7 deletions.
2 changes: 1 addition & 1 deletion bitcoin/examples/ecdsa-psbt.rs
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,7 @@ fn main() -> Result<()> {
let finalized = online.finalize_psbt(signed)?;

// You can use `bt sendrawtransaction` to broadcast the extracted transaction.
let tx = finalized.extract_tx();
let tx = finalized.extract_tx_unchecked_high_fee();
tx.verify(|_| Some(previous_output())).expect("failed to verify transaction");

let hex = encode::serialize_hex(&tx);
Expand Down
6 changes: 3 additions & 3 deletions bitcoin/examples/taproot-psbt.rs
Original file line number Diff line number Diff line change
Expand Up @@ -319,7 +319,7 @@ fn generate_bip86_key_spend_tx(
});

// EXTRACTOR
let tx = psbt.extract_tx();
let tx = psbt.extract_tx_unchecked_high_fee();
tx.verify(|_| {
Some(TxOut {
value: from_amount,
Expand Down Expand Up @@ -553,7 +553,7 @@ impl BenefactorWallet {
});

// EXTRACTOR
let tx = psbt.extract_tx();
let tx = psbt.extract_tx_unchecked_high_fee();
tx.verify(|_| {
Some(TxOut { value: input_value, script_pubkey: output_script_pubkey.clone() })
})
Expand Down Expand Up @@ -695,7 +695,7 @@ impl BeneficiaryWallet {
});

// EXTRACTOR
let tx = psbt.extract_tx();
let tx = psbt.extract_tx_unchecked_high_fee();
tx.verify(|_| {
Some(TxOut { value: input_value, script_pubkey: input_script_pubkey.clone() })
})
Expand Down
187 changes: 185 additions & 2 deletions bitcoin/src/psbt/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,64 @@ pub struct Psbt {
pub outputs: Vec<Output>,
}

// This is only used for a single internal function
enum GetFeeResult {
Ok { fee: u64, output_value: u64 },
MissingInputValue,
UnderFlow,
}

#[derive(Debug, Clone, PartialEq)]
/// This error is returned when extracting a Transaction from a Psbt.
pub enum ExtractTxError {
/// The Fee Rate (`satoshis / vBytes`) is too high
AbsurdFeeRate {
/// The Fee Rate (`satoshis / vBytes`)
fee_rate: f64,
/// The extracted Transaction (use this to ignore the error)
transaction: Transaction,
},
/// The Fee Ratio (`fee / total_output_value`) is too high
AbsurdFeeRatio {
/// The Fee Ratio (`fee / total_output_value`)
fee_ratio: f64,
/// The extracted Transaction (use this to ignore the error)
transaction: Transaction,
},
/// One or more of the inputs lacks value information (witness_utxo or non_witness_utxo)
MissingInputValue {
/// The extracted Transaction (use this to ignore the error)
transaction: Transaction,
},
/// Input value is less than Output Value, and the transaction would be invalid.
SendingTooMuch {
/// The original Psbt is returned untouched.
psbt: Psbt,
},
}

impl fmt::Display for ExtractTxError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
ExtractTxError::AbsurdFeeRate { fee_rate, .. } =>
write!(f, "An absurdly high fee rate of {} sats/vByte", fee_rate),
ExtractTxError::AbsurdFeeRatio { fee_ratio, .. } =>
write!(f, "An absurdly high fee ratio of {} (fee/total_output_value)", fee_ratio),
ExtractTxError::MissingInputValue { .. } => write!(
f,
"One of the inputs lacked value information (witness_utxo or non_witness_utxo)"
),
ExtractTxError::SendingTooMuch { .. } => write!(
f,
"Transaction would be invalid due to output value being greater than input value."
),
}
}
}

#[cfg(feature = "std")]
impl std::error::Error for ExtractTxError {}

impl Psbt {
/// Returns an iterator for the funding UTXOs of the psbt
///
Expand Down Expand Up @@ -121,8 +179,98 @@ impl Psbt {
Ok(psbt)
}

/// Extracts the `Transaction` from a PSBT by filling in the available signature information.
pub fn extract_tx(self) -> Transaction {
#[inline]
fn get_input_total_value(&self) -> Option<u64> {
let mut input_amount = 0;
for (vin, psbtin) in self.unsigned_tx.input.iter().zip(self.inputs.iter()) {
let prev_vout = vin.previous_output.vout;

// Return early if any input is missing both utxo types.
let value = psbtin.witness_utxo.as_ref().map(|o| o.value.to_sat()).or_else(|| {
psbtin
.non_witness_utxo
.as_ref()
.and_then(|tx| tx.output.get(prev_vout as usize))
.map(|o| o.value.to_sat())
})?;

input_amount += value;
}

Some(input_amount)
}

#[inline]
fn get_output_total_value(&self) -> u64 {
self.unsigned_tx.output.iter().map(|o| o.value.to_sat()).sum::<u64>()
}

#[inline]
fn get_fee(&self) -> GetFeeResult {
let input_value = self.get_input_total_value();
let output_value = self.get_output_total_value();
match input_value.map(|i| i.checked_sub(output_value)) {
Some(Some(fee)) => GetFeeResult::Ok { fee, output_value },
Some(None) => GetFeeResult::UnderFlow,
None => GetFeeResult::MissingInputValue,
}
}

/// The default `max_fee_rate` value used for extracting transactions with [`extract_tx`]
///
/// As of 2023, even the biggest overpayers during the highest fee markets only paid around
/// 1000 sats/vByte. 25k sats/vByte is obviously a mistake at this point.
///
/// [`extract_tx`]: Psbt::extract_tx
pub const DEFAULT_MAX_FEE_RATE: f64 = 25000.0_f64;

/// The default `max_fee_ratio` value used for extracting transactions with [`extract_tx`]
///
/// On average, transactions tend to have a 0.3 ratio of fees to output value.
/// Looking at transactions around block 807060, it seems that a large majority of fees
/// fall under 1.0 (99.99%) Occasionally there will be outliers (especially with small amounts)
/// Fee ratio of 10 means "I paid 10x of my send amount + change amount combined in fees."
///
/// [`extract_tx`]: Psbt::extract_tx
pub const DEFAULT_MAX_FEE_RATIO: f64 = 10.0_f64;

/// Extracts the [`Transaction`] from a [`Psbt`] by filling in the available signature information.
///
/// ## Errors
///
/// [`ExtractTxError`] variants will contain either the [`Psbt`] itself or the [`Transaction`]
/// that was extracted. These can be extracted from the Errors in order to recover.
/// See the error documentation for info on the variants. In general, it covers large fees.
pub fn extract_tx(self) -> Result<Transaction, ExtractTxError> {
self.internal_extract_tx_with_checks(
Self::DEFAULT_MAX_FEE_RATE,
Self::DEFAULT_MAX_FEE_RATIO,
)
}

/// Extracts the [`Transaction`] from a [`Psbt`] by filling in the available signature information.
///
/// ## Errors
///
/// See [`extract_tx`].
///
/// [`extract_tx`]: Psbt::extract_tx
pub fn extract_tx_with_maximum_feerate_and_feeratio(
self,
max_fee_rate: f64,
max_fee_ratio: f64,
) -> Result<Transaction, ExtractTxError> {
self.internal_extract_tx_with_checks(max_fee_rate, max_fee_ratio)
}

/// Extracts the [`Transaction`] from a [`Psbt`] by filling in the available signature information
/// without performing any checks.
///
/// This might pay an absurdly high fee, or be an invalid transaction.
pub fn extract_tx_unchecked_high_fee(self) -> Transaction { self.internal_extract_tx() }

#[inline]
fn internal_extract_tx(self) -> Transaction {
let mut tx: Transaction = self.unsigned_tx;

for (vin, psbtin) in tx.input.iter_mut().zip(self.inputs.into_iter()) {
Expand All @@ -133,6 +281,41 @@ impl Psbt {
tx
}

#[inline]
fn internal_extract_tx_with_checks(
self,
max_fee_rate: f64,
max_fee_ratio: f64,
) -> Result<Transaction, ExtractTxError> {
let (fee, output_value) = match self.get_fee() {
GetFeeResult::Ok { fee, output_value } => (fee as f64, output_value as f64),
GetFeeResult::MissingInputValue =>
return Err(ExtractTxError::MissingInputValue {
transaction: self.internal_extract_tx(),
}),
GetFeeResult::UnderFlow => return Err(ExtractTxError::SendingTooMuch { psbt: self }),
};

// Note: Move prevents usage of &self from now on.
let tx = self.internal_extract_tx();

// Now that the extracted Transaction is made, decide how to return it.
let fee_rate = fee / tx.vsize() as f64;
let fee_ratio = fee / output_value;
// Prefer to return an AbsurdFeeRate error when both trigger.
if fee_rate > max_fee_rate {
return Err(ExtractTxError::AbsurdFeeRate { fee_rate, transaction: tx });
}
// Don't return error for OP_RETURN only transactions etc.
// Fee ratio is meaningless for 0 value transactions.
// In their case we just rely on absurd fee rate check.
if output_value > 0.0 && fee_ratio > max_fee_ratio {
return Err(ExtractTxError::AbsurdFeeRatio { fee_ratio, transaction: tx });
}

Ok(tx)
}

/// Combines this [`Psbt`] with `other` PSBT as described by BIP 174.
///
/// In accordance with BIP 174 this function is commutative i.e., `A.combine(B) == B.combine(A)`
Expand Down
2 changes: 1 addition & 1 deletion bitcoin/tests/psbt.rs
Original file line number Diff line number Diff line change
Expand Up @@ -380,7 +380,7 @@ fn finalize(psbt: Psbt) -> Psbt {
fn extract_transaction(psbt: Psbt) -> Transaction {
let expected_tx_hex = include_str!("data/extract_tx_hex");

let tx = psbt.extract_tx();
let tx = psbt.extract_tx_unchecked_high_fee();

let got = serialize_hex(&tx);
assert_eq!(got, expected_tx_hex);
Expand Down

0 comments on commit 63f500e

Please sign in to comment.