Skip to content
Open
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
36 changes: 35 additions & 1 deletion src/wallet/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ use alloc::{
};
#[cfg(all(bdk_wallet_unstable, feature = "bdk-tx"))]
use bdk_tx::bdk_coin_select;
use bitcoin::{Amount, BlockHash, Network, OutPoint, Sequence, Txid, absolute, psbt};
use bitcoin::{Amount, BlockHash, Network, OutPoint, Sequence, Txid, Weight, absolute, psbt};
use core::fmt;

/// The error type when loading a [`Wallet`] from a [`ChangeSet`].
Expand Down Expand Up @@ -199,6 +199,17 @@ pub enum CreateTxError {
NoUtxosSelected,
/// Output created is under the dust limit, 546 satoshis
OutputBelowDustLimit(usize),
/// Assembled transaction exceeds [`bitcoin::policy::MAX_STANDARD_TX_WEIGHT`].
///
/// The reported `weight` is the estimated signed weight (unsigned transaction plus each
/// input's satisfaction weight). Standard mempools reject transactions above this limit,
/// so building them is treated as an error.
TxWeightLimitExceeded {
/// Estimated signed transaction weight.
weight: Weight,
/// Standardness weight limit that was exceeded.
limit: Weight,
},
/// There was an error with coin selection
CoinSelection(coin_selection::InsufficientFunds),
/// Cannot build a tx without recipients
Expand Down Expand Up @@ -269,6 +280,12 @@ impl fmt::Display for CreateTxError {
CreateTxError::OutputBelowDustLimit(limit) => {
write!(f, "Output below the dust limit: {limit}")
}
CreateTxError::TxWeightLimitExceeded { weight, limit } => {
write!(
f,
"Transaction weight {weight} exceeds the standardness limit of {limit}"
)
}
CreateTxError::CoinSelection(e) => e.fmt(f),
CreateTxError::NoRecipients => {
write!(f, "Cannot build tx without recipients")
Expand Down Expand Up @@ -387,6 +404,17 @@ pub enum CreatePsbtError {
/// After coin selection, all outputs fell below the dust threshold and were
/// dropped to fees.
AllOutputsBelowDust,
/// Assembled transaction exceeds [`bitcoin::policy::MAX_STANDARD_TX_WEIGHT`].
///
/// The reported `weight` is the estimated signed weight (unsigned transaction plus each
/// input's satisfaction weight). Standard mempools reject transactions above this limit,
/// so building them is treated as an error.
TxWeightLimitExceeded {
/// Estimated signed transaction weight.
weight: Weight,
/// Standardness weight limit that was exceeded.
limit: Weight,
},
/// Non-sufficient funds.
InsufficientFunds(bdk_coin_select::InsufficientFunds),
/// In order to use the [`add_global_xpubs`] option, every extended key in the descriptor must
Expand Down Expand Up @@ -414,6 +442,12 @@ impl fmt::Display for CreatePsbtError {
Self::InsufficientFunds(e) => write!(f, "{e}"),
Self::NoRecipients => write!(f, "no output destinations were configured"),
Self::AllOutputsBelowDust => write!(f, "all outputs are below the dust threshold",),
Self::TxWeightLimitExceeded { weight, limit } => {
write!(
f,
"transaction weight {weight} exceeds the standardness limit of {limit}"
)
}
Self::MissingKeyOrigin(e) => write!(f, "missing key origin: {e}"),
Self::Plan(op) => write!(f, "failed to create a plan for txout with outpoint {op}"),
Self::Psbt(e) => write!(f, "{e}"),
Expand Down
274 changes: 274 additions & 0 deletions src/wallet/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1505,6 +1505,12 @@ impl Wallet {
}
};

let satisfaction_by_outpoint: HashMap<OutPoint, InputSatisfaction> = required_utxos
.iter()
.chain(optional_utxos.iter())
.map(|weighted| (weighted.utxo.outpoint(), self.input_satisfaction(weighted)))
.collect();

// Get drain script.
let mut drain_index = Option::<(KeychainKind, u32)>::None;
let drain_script = match params.drain_to {
Expand Down Expand Up @@ -1599,6 +1605,18 @@ impl Wallet {
// Sort inputs/outputs according to the chosen algorithm.
params.ordering.sort_tx_with_aux_rand(&mut tx, rng);

// Reject transactions that would exceed the standardness weight limit once signed.
// `tx.weight()` is the unsigned weight (empty witnesses). Each input contributes the
// satisfaction weight tracked on its selection candidate, including foreign UTXOs.
let satisfactions = tx.input.iter().map(|txin| {
satisfaction_by_outpoint
.get(&txin.previous_output)
.copied()
.unwrap_or_else(|| self.satisfaction_for_outpoint(txin.previous_output))
});
check_max_standard_tx_weight(&tx, satisfactions)
.map_err(|(weight, limit)| CreateTxError::TxWeightLimitExceeded { weight, limit })?;

let psbt = self.complete_transaction(tx, coin_selection.selected, params)?;

// Recording changes to the change keychain.
Expand Down Expand Up @@ -2944,6 +2962,173 @@ impl Wallet {
})
.collect()
}

/// Satisfaction of a coin-selection candidate, including foreign UTXOs.
///
/// Local inputs use the descriptor's [`max_weight_to_satisfy`]. Foreign inputs use the weight
/// supplied with the UTXO. Segwit (native or nested) is recorded separately so the standardness
/// check can apply BIP 141 witness overhead.
///
/// [`max_weight_to_satisfy`]: miniscript::Descriptor::max_weight_to_satisfy
fn input_satisfaction(&self, weighted: &WeightedUtxo) -> InputSatisfaction {
let segwit = match &weighted.utxo {
Utxo::Local(local) => self.descriptor_spends_with_witness(local.keychain),
Utxo::Foreign { psbt_input, .. } => spends_with_witness(
weighted.utxo.txout().script_pubkey.as_script(),
Some(psbt_input),
),
};
InputSatisfaction {
weight: weighted.satisfaction_weight,
segwit,
}
}

/// Fallback satisfaction when an outpoint was not part of coin selection.
///
/// Local wallet UTXOs use the descriptor's [`max_weight_to_satisfy`]. Unknown outpoints
/// contribute nothing.
///
/// [`max_weight_to_satisfy`]: miniscript::Descriptor::max_weight_to_satisfy
fn satisfaction_for_outpoint(&self, outpoint: OutPoint) -> InputSatisfaction {
let Some(utxo) = self.get_utxo(outpoint) else {
return InputSatisfaction {
weight: Weight::ZERO,
segwit: false,
};
};
let weight = self
.public_descriptor(utxo.keychain)
.max_weight_to_satisfy()
.unwrap_or(Weight::ZERO);
InputSatisfaction {
weight,
segwit: self.descriptor_spends_with_witness(utxo.keychain),
}
}

/// Whether spends of this keychain serialize a witness (native or nested segwit, or taproot).
fn descriptor_spends_with_witness(&self, keychain: KeychainKind) -> bool {
let desc = self.public_descriptor(keychain);
desc.is_witness() || desc.is_taproot()
}
}

/// Extra weight of satisfying one input, and whether that satisfaction is a witness.
///
/// `weight` is the miniscript [`max_weight_to_satisfy`] delta: the difference between
/// `TxIn::default().segwit_weight()` and the satisfied input. That delta does not include the
/// 1-WU empty witness stack length already assumed by `segwit_weight`.
///
/// [`max_weight_to_satisfy`]: miniscript::Descriptor::max_weight_to_satisfy
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
struct InputSatisfaction {
weight: Weight,
/// `true` when satisfying this input serializes a witness (native segwit, nested segwit, or
/// taproot). Pure legacy scriptSig satisfaction is `false`.
segwit: bool,
}

/// Whether `script_pubkey` (or the PSBT metadata spending it) puts satisfaction in a witness.
///
/// Native segwit and taproot are witness programs. Nested segwit is a P2SH script pubkey, so it
/// is recognized from the PSBT input: a witness UTXO, witness script, final witness, or a redeem
/// script that is itself a witness program.
fn spends_with_witness(script_pubkey: &bitcoin::Script, psbt_input: Option<&psbt::Input>) -> bool {
if script_pubkey.witness_version().is_some() {
return true;
}
let Some(psbt_input) = psbt_input else {
return false;
};
if psbt_input.witness_utxo.is_some()
|| psbt_input.final_script_witness.is_some()
|| psbt_input.witness_script.is_some()
{
return true;
}
psbt_input
.redeem_script
.as_ref()
.is_some_and(|redeem| redeem.witness_version().is_some())
}

/// [`bdk_tx::Input`] satisfaction, using the weight already tracked on the selected input.
///
/// Planned inputs created from a PSBT do not report [`Input::is_segwit`] unless
/// `final_script_witness` is set, so witness-ness also comes from the prevout and PSBT metadata.
#[cfg(all(bdk_wallet_unstable, feature = "bdk-tx"))]
fn selection_input_satisfaction(input: &Input) -> InputSatisfaction {
let segwit = if input.plan().is_some() {
input.is_segwit()
} else {
spends_with_witness(
input.prev_txout().script_pubkey.as_script(),
input.psbt_input(),
)
};
InputSatisfaction {
weight: Weight::from_wu(input.satisfaction_weight()),
segwit,
}
}

/// Signed weight of an unsigned transaction.
///
/// `tx.weight()` is the legacy serialization (empty witnesses, no BIP 141 marker). Satisfaction
/// weights are miniscript deltas from `TxIn::default().segwit_weight()`.
///
/// * Pure legacy (no witness satisfaction): overhead is 0. The delta already accounts for the
/// scriptSig.
/// * Any witness satisfaction: overhead is 2 WU (marker and flag) plus 1 WU per input. BIP 141
/// writes a witness stack length for every input, and the miniscript delta assumed that byte
/// was already present in `segwit_weight`.
///
/// Returns [`None`] when the sum overflows `u64`.
fn estimated_signed_weight(
tx: &Transaction,
satisfactions: impl IntoIterator<Item = InputSatisfaction>,
) -> Option<Weight> {
let (satisfaction, any_segwit) =
satisfactions
.into_iter()
.try_fold((Weight::ZERO, false), |(acc, any_segwit), sat| {
acc.checked_add(sat.weight)
.map(|sum| (sum, any_segwit || sat.segwit))
})?;

let witness_overhead = if any_segwit {
// One stack-length byte per input, plus the 2-byte marker/flag.
// `usize` fits in `u64` on every supported target.
let n_inputs = tx.input.len() as u64;
Weight::from_wu(2).checked_add(Weight::from_wu(n_inputs))?
} else {
Weight::ZERO
};

tx.weight()
.checked_add(satisfaction)?
.checked_add(witness_overhead)
}

/// Estimate the signed weight of an assembled unsigned transaction and reject it when it
/// exceeds [`bitcoin::policy::MAX_STANDARD_TX_WEIGHT`].
///
/// On overflow the estimated weight is reported as [`Weight::MAX`], which is above the limit, so
/// callers surface [`CreateTxError::TxWeightLimitExceeded`] instead of panicking or wrapping.
fn check_max_standard_tx_weight(
tx: &Transaction,
satisfactions: impl IntoIterator<Item = InputSatisfaction>,
) -> Result<(), (Weight, Weight)> {
let limit = Weight::from_wu(u64::from(bitcoin::policy::MAX_STANDARD_TX_WEIGHT));
let Some(weight) = estimated_signed_weight(tx, satisfactions) else {
return Err((Weight::MAX, limit));
};
if weight > limit {
Err((weight, limit))
} else {
Ok(())
}
}

/// Methods to construct sync/full-scan requests for spk-based chain sources.
Expand Down Expand Up @@ -3220,6 +3405,7 @@ impl Wallet {
/// - A manually selected input is missing from the wallet, or could not be planned
/// - The input value is insufficient to fund the outputs
/// - Failure to complete coin selection
/// - The assembled transaction exceeds [`bitcoin::policy::MAX_STANDARD_TX_WEIGHT`]
/// - Failure to create or update the PSBT.
///
/// # Change address
Expand Down Expand Up @@ -3424,6 +3610,12 @@ impl Wallet {
)
.map_err(CreatePsbtError::Psbt)?;

// Planned and foreign inputs are not in the wallet UTXO set, so their satisfaction
// weights live on the selection candidates rather than on a local descriptor.
let satisfactions = selection.inputs().iter().map(selection_input_satisfaction);
check_max_standard_tx_weight(&psbt.unsigned_tx, satisfactions)
.map_err(|(weight, limit)| CreatePsbtError::TxWeightLimitExceeded { weight, limit })?;

// Add global xpubs.
if params.add_global_xpubs {
for xpub in self
Expand Down Expand Up @@ -4204,4 +4396,86 @@ mod test {
assert_eq!(deprecated_finalized, signers_finalized);
assert_eq!(deprecated_psbt, signers_psbt);
}

fn p2pkh_script() -> ScriptBuf {
use bitcoin::hashes::Hash;
ScriptBuf::new_p2pkh(&bitcoin::PubkeyHash::all_zeros())
}

fn p2wpkh_script() -> ScriptBuf {
use bitcoin::hashes::Hash;
ScriptBuf::new_p2wpkh(&bitcoin::WPubkeyHash::all_zeros())
}

/// Unsigned tx: `n_inputs` empty inputs and one output. Witnesses are empty, so
/// [`Transaction::weight`] is the legacy serialization.
fn unsigned_tx(n_inputs: usize, output_script: ScriptBuf) -> Transaction {
Transaction {
version: transaction::Version::TWO,
lock_time: absolute::LockTime::ZERO,
input: (0..n_inputs)
.map(|_| bitcoin::TxIn {
previous_output: OutPoint::null(),
script_sig: ScriptBuf::new(),
sequence: Sequence::MAX,
witness: Witness::new(),
})
.collect(),
output: vec![TxOut {
value: Amount::from_sat(50_000),
script_pubkey: output_script,
}],
}
}

fn sat(weight: u64, segwit: bool) -> InputSatisfaction {
InputSatisfaction {
weight: Weight::from_wu(weight),
segwit,
}
}

#[test]
fn signed_weight_matches_bip141_overhead() {
// Review table: pure legacy must not add the segwit marker. P2PKH satisfaction is the
// scriptSig delta (428 WU); actual signed weight is unsigned + 428.
let legacy = unsigned_tx(1, p2pkh_script());
assert_eq!(legacy.weight(), Weight::from_wu(340));
assert_eq!(
estimated_signed_weight(&legacy, [sat(428, false)]).unwrap(),
Weight::from_wu(768)
);

// Mixed: 1 P2WPKH (107 WU) + 2 P2PKH (428 WU). Overhead is 2 + K, K = 3.
let mixed = unsigned_tx(3, p2wpkh_script());
assert_eq!(mixed.weight(), Weight::from_wu(656));
assert_eq!(
estimated_signed_weight(&mixed, [sat(107, true), sat(428, false), sat(428, false)])
.unwrap(),
Weight::from_wu(1_624)
);

// Pure P2WPKH, K = 100. Overhead is 2 + 100; satisfaction is 107 WU each.
let segwit = unsigned_tx(100, p2wpkh_script());
assert_eq!(segwit.weight(), Weight::from_wu(16_564));
let satisfactions = (0..100).map(|_| sat(107, true));
assert_eq!(
estimated_signed_weight(&segwit, satisfactions).unwrap(),
Weight::from_wu(27_366)
);
}

#[test]
fn signed_weight_overflow_is_tx_weight_limit_exceeded() {
let tx = unsigned_tx(1, p2wpkh_script());
let limit = Weight::from_wu(u64::from(bitcoin::policy::MAX_STANDARD_TX_WEIGHT));
// Summing the satisfaction itself overflows, before the base tx weight is added.
let overflow =
check_max_standard_tx_weight(&tx, [sat(u64::MAX - 10, false), sat(100, false)]);
assert_eq!(overflow, Err((Weight::MAX, limit)));

// A single astronomical satisfaction plus the unsigned tx weight overflows.
let astronomical = check_max_standard_tx_weight(&tx, [sat(u64::MAX - 200, true)]);
assert_eq!(astronomical, Err((Weight::MAX, limit)));
}
}
Loading