From e45d5977c190d675a62a306696bd006499fd74d1 Mon Sep 17 00:00:00 2001 From: Abiodun Date: Tue, 14 Apr 2026 15:15:09 +0100 Subject: [PATCH 1/3] feat(selector): add SelectorParams builder with standardness checks --- examples/synopsis.rs | 2 +- src/selector.rs | 231 ++++++++++++++++++++++++++++++++++++++++--- src/utils.rs | 16 ++- 3 files changed, 236 insertions(+), 13 deletions(-) diff --git a/examples/synopsis.rs b/examples/synopsis.rs index 4f8dc6c..81ca305 100644 --- a/examples/synopsis.rs +++ b/examples/synopsis.rs @@ -143,7 +143,7 @@ fn main() -> anyhow::Result<()> { // For waste optimization when deciding change. change_longterm_feerate: Some(longterm_feerate), change_min_value: None, - change_dust_relay_feerate: None, + dust_relay_feerate: None, // This ensures that we satisfy mempool-replacement policy rules 4 and 6. replace: Some(rbf_params), }, diff --git a/src/selector.rs b/src/selector.rs index 6fc2e8a..6400fa9 100644 --- a/src/selector.rs +++ b/src/selector.rs @@ -3,12 +3,16 @@ use bitcoin::{Amount, FeeRate, ScriptBuf, Transaction, Weight}; use miniscript::bitcoin; use crate::{ - DefiniteDescriptor, FeeRateExt, InputCandidates, InputGroup, Output, ScriptSource, Selection, + utils::is_standard_script, DefiniteDescriptor, FeeRateExt, InputCandidates, InputGroup, Output, + ScriptSource, Selection, }; use alloc::boxed::Box; use alloc::vec::Vec; use core::fmt::{self, Debug}; +/// Maximum aggregate size in bytes of all `OP_RETURN` `scriptPubKey`s in a standard transaction. +pub const MAX_OP_RETURN_BYTES: usize = 100_000; + /// A coin selector #[derive(Debug, Clone)] pub struct Selector<'c> { @@ -22,12 +26,8 @@ pub struct Selector<'c> { /// Parameters for creating tx. /// -/// TODO: Create a builder interface on this that does checks. I.e. -/// * Error if recipient is dust. -/// * Error on multi OP_RETURN outputs. -/// * Error on anything that does not satisfy mempool policy. -/// If the caller wants to create non-mempool-policy conforming txs, they can just fill in the -/// fields directly. +/// Use [`SelectorParams::builder`] for the validated construction path, or +/// construct directly via the public fields to opt out of standardness checks. #[derive(Debug)] pub struct SelectorParams { /// Target feerate. @@ -45,10 +45,10 @@ pub struct SelectorParams { /// this. For descriptors it is computed automatically; for raw scripts it must be provided. pub change_script: ChangeScript, - /// Dust relay feerate used to calculate the dust threshold for change outputs. + /// Dust relay feerate used to calculate the dust threshold for outputs (target and change). /// /// If `None`, defaults to 3 sat/vB (the Bitcoin Core default for `-dustrelayfee`). - pub change_dust_relay_feerate: Option, + pub dust_relay_feerate: Option, /// Minimum change value. /// @@ -251,7 +251,7 @@ impl SelectorParams { change_min_value: None, change_longterm_feerate: None, replace: None, - change_dust_relay_feerate: None, + dust_relay_feerate: None, } } @@ -284,7 +284,7 @@ impl SelectorParams { /// Returns [`SelectorError::Miniscript`] if the change descriptor is inherently unsatisfiable. pub fn to_cs_change_policy(&self) -> Result { let change_script = self.change_script.source().script(); - let min_non_dust = self.change_dust_relay_feerate.map_or_else( + let min_non_dust = self.dust_relay_feerate.map_or_else( || change_script.minimal_non_dust(), |r| change_script.minimal_non_dust_custom(r), ); @@ -320,8 +320,217 @@ impl SelectorParams { }, ) } + + /// Run the output-side standardness checks: dust, `OP_RETURN` policy, and + /// standard script types. Mirrors the output-only part of Bitcoin Core's + /// `IsStandardTx`; post-selection checks live in [`crate::policy::MempoolPolicy`]. + /// + /// Called automatically by [`SelectorParamsBuilder::build`]. + pub fn check_standardness(&self) -> Result<(), SelectorParamsError> { + let mut op_return_total_bytes: usize = 0; + + for output in &self.target_outputs { + let spk = output.script_pubkey(); + + if spk.is_op_return() { + if output.value > Amount::ZERO { + return Err(SelectorParamsError::OpReturnWithValue); + } + + // Aggregate cap across all OP_RETURN outputs, matching + // Bitcoin Core v30's `-datacarriersize`. + op_return_total_bytes = op_return_total_bytes.saturating_add(spk.len()); + + continue; + } + + if !is_standard_script(&spk) { + return Err(SelectorParamsError::NonStandardScriptType); + } + + let required = match self.dust_relay_feerate { + Some(rate) => spk.minimal_non_dust_custom(rate), + None => spk.minimal_non_dust(), + }; + if output.value < required { + return Err(SelectorParamsError::DustOutput { + actual: output.value, + required, + }); + } + } + + if op_return_total_bytes > MAX_OP_RETURN_BYTES { + return Err(SelectorParamsError::OpReturnTooLarge { + actual: op_return_total_bytes, + max: MAX_OP_RETURN_BYTES, + }); + } + + Ok(()) + } + + /// Start a validated builder. + /// + /// The two required fields are taken eagerly so the builder cannot be + /// constructed in an incomplete state. Outputs and optional fields are + /// added with chained setters; [`build`](SelectorParamsBuilder::build) + /// runs [`check_standardness`](Self::check_standardness) and returns the params. + pub fn builder(target_feerate: FeeRate, change_script: ChangeScript) -> SelectorParamsBuilder { + SelectorParamsBuilder { + target_feerate, + target_outputs: Vec::new(), + change_script, + dust_relay_feerate: None, + change_min_value: None, + change_longterm_feerate: None, + replace: None, + } + } +} + +/// Builder for [`SelectorParams`] that enforces output-side standardness. +/// +/// Callers who need to bypass validation should construct [`SelectorParams`] +/// directly via its public fields. +#[derive(Debug)] +#[must_use] +pub struct SelectorParamsBuilder { + target_feerate: FeeRate, + target_outputs: Vec, + change_script: ChangeScript, + dust_relay_feerate: Option, + change_min_value: Option, + change_longterm_feerate: Option, + replace: Option, +} + +impl SelectorParamsBuilder { + /// Add a single target output. + pub fn add_output(mut self, output: impl Into) -> Self { + self.target_outputs.push(output.into()); + self + } + + /// Add multiple target outputs. + pub fn add_outputs(mut self, outputs: I) -> Self + where + I: IntoIterator, + I::Item: Into, + { + self.target_outputs + .extend(outputs.into_iter().map(Into::into)); + self + } + + /// Override the target feerate. + pub fn target_feerate(mut self, feerate: FeeRate) -> Self { + self.target_feerate = feerate; + self + } + + /// Override the change script source. + pub fn change_script(mut self, change_script: ChangeScript) -> Self { + self.change_script = change_script; + self + } + + /// Override the dust relay feerate used to compute dust thresholds for all outputs (target and change) + pub fn dust_relay_feerate(mut self, feerate: FeeRate) -> Self { + self.dust_relay_feerate = Some(feerate); + self + } + + /// Set a minimum change value. + pub fn change_min_value(mut self, value: Amount) -> Self { + self.change_min_value = Some(value); + self + } + + /// Enable waste-optimized change decisions using the given long-term feerate. + pub fn change_longterm_feerate(mut self, feerate: FeeRate) -> Self { + self.change_longterm_feerate = Some(feerate); + self + } + + /// Configure this transaction as a replacement (BIP 125) for the given + /// original transactions. + pub fn replace(mut self, replace: RbfParams) -> Self { + self.replace = Some(replace); + self + } + + /// Validate and produce a [`SelectorParams`]. + /// + /// Runs the full output-side standardness check; see + /// [`SelectorParams::check_standardness`] for the exact rules. + pub fn build(self) -> Result { + let params = SelectorParams { + target_feerate: self.target_feerate, + target_outputs: self.target_outputs, + change_script: self.change_script, + dust_relay_feerate: self.dust_relay_feerate, + change_min_value: self.change_min_value, + change_longterm_feerate: self.change_longterm_feerate, + replace: self.replace, + }; + params.check_standardness()?; + Ok(params) + } } +/// Errors when building `SelectorParams`. +#[derive(Debug)] +#[non_exhaustive] +pub enum SelectorParamsError { + /// Output value is below dust threshold + DustOutput { + /// Actual output value. + actual: Amount, + /// Required minimum value. + required: Amount, + }, + /// The combined size of all `OP_RETURN` outputs exceeds the aggregate cap. + OpReturnTooLarge { + /// Total bytes across all OP_RETURN. + actual: usize, + /// Maximum allowed aggregate size ([`MAX_OP_RETURN_BYTES`]). + max: usize, + }, + /// OP_RETURN output has value greater than zero + OpReturnWithValue, + /// An output uses a non-standard script type. + NonStandardScriptType, +} + +impl core::fmt::Display for SelectorParamsError { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + match self { + Self::DustOutput { actual, required } => { + write!( + f, + "output value {actual} is below the dust threshold of {required}" + ) + } + Self::OpReturnTooLarge { actual, max } => { + write!( + f, + "aggregate OP_RETURN scriptPubKey size is {actual} bytes, which exceeds the -datacarriersize limit of {max} bytes", + ) + } + Self::OpReturnWithValue => { + write!(f, "OP_RETURN output must have zero value") + } + Self::NonStandardScriptType => { + write!(f, "an output uses a non-standard script type") + } + } + } +} + +#[cfg(feature = "std")] +impl std::error::Error for SelectorParamsError {} + /// Error when the selection is impossible with the input candidates #[derive(Debug)] pub struct CannotMeetTarget; diff --git a/src/utils.rs b/src/utils.rs index 9fcfe67..0eb29d1 100644 --- a/src/utils.rs +++ b/src/utils.rs @@ -3,7 +3,7 @@ use alloc::vec::Vec; use miniscript::bitcoin::{ absolute::{self, LockTime}, transaction::Version, - Sequence, Transaction, + Script, Sequence, Transaction, }; #[cfg(feature = "std")] use rand::Rng; @@ -180,3 +180,17 @@ fn random_range(rng: &mut impl RngCore, n: u32) -> u32 { } } } + +/// Returns `true` if the given script is a recognized standard output script type. +/// +/// Mirrors the output-script-type checks in Bitcoin Core's `IsStandard()`. +/// Excludes bare multisig (widely disabled at the relay layer). +pub fn is_standard_script(script: &Script) -> bool { + script.is_p2pk() + || script.is_p2pkh() + || script.is_p2sh() + || script.is_p2wpkh() + || script.is_p2wsh() + || script.is_p2tr() + || script.is_op_return() +} From f4ed2be8aa1e6b27931344cf45fb70ad6e706b13 Mon Sep 17 00:00:00 2001 From: Abiodun Date: Tue, 14 Apr 2026 15:26:23 +0100 Subject: [PATCH 2/3] feat(policy): add MempoolPolicy for post-selection mempool standardness checks --- src/input.rs | 21 +++ src/lib.rs | 2 + src/policy.rs | 352 ++++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 375 insertions(+) create mode 100644 src/policy.rs diff --git a/src/input.rs b/src/input.rs index b9dee4b..aeeb110 100644 --- a/src/input.rs +++ b/src/input.rs @@ -510,6 +510,27 @@ impl Input { pub fn is_segwit(&self) -> bool { self.plan.is_segwit() } + + /// Returns the number of witness stack items, if witness data is present. + /// + /// Returns `None` if this input has no witness data yet (e.g. an unsigned plan-based input). + pub fn witness_item_count(&self) -> Option { + // Finalized + if let Some(psbt_input) = self.psbt_input() { + if let Some(witness) = &psbt_input.final_script_witness { + return Some(witness.len()); + } + } + + // Unfinalized + if let Some(plan) = self.plan() { + if plan.witness_version().is_some() { + return Some(plan.witness_template().len()); + } + } + + None + } } /// Input group. Cannot be empty. diff --git a/src/lib.rs b/src/lib.rs index 2699fc3..9fac2df 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -13,6 +13,7 @@ mod finalizer; mod input; mod input_candidates; mod output; +mod policy; mod rbf; mod selection; mod selector; @@ -27,6 +28,7 @@ pub use miniscript; pub use miniscript::bitcoin; use miniscript::{DefiniteDescriptorKey, Descriptor}; pub use output::*; +pub use policy::*; pub use rbf::*; pub use selection::*; pub use selector::*; diff --git a/src/policy.rs b/src/policy.rs new file mode 100644 index 0000000..4b63c64 --- /dev/null +++ b/src/policy.rs @@ -0,0 +1,352 @@ +use crate::{ + bitcoin::{ + absolute::{self, LockTime}, + policy::MAX_STANDARD_TX_WEIGHT, + transaction::Version, + Amount, OutPoint, Transaction, Weight, + }, + utils::is_standard_script, + Input, Selection, +}; + +/// Default minimum relay feerate. +/// +/// Lowered from 1000 sat/kvB to 100 sat/kvB in Bitcoin Core. +const DEFAULT_MIN_RELAY_TX_FEE: u64 = 100; + +/// Maximum standardness weight for a TRUC (version-3) transaction. +const MAX_TRUC_TX_WEIGHT: u64 = 40_000; + +/// Minimum non-witness size for a standard transaction +/// +/// Lowered from 82 to 65 in Bitcoin Core. +const MIN_STANDARD_TX_NONWITNESS_SIZE: u32 = 65; + +/// Maximum witness stack items allowed under standard mempool policy. +const MAX_WITNESS_STACK_ITEMS: usize = 100; + +/// Mempool acceptance policy checks for a fully-built [`Selection`]. +/// +/// Pairs with [`SelectorParams::check_standardness`] for output-only checks +/// (dust, OP_RETURN, output script types) that run before coin selection. +pub struct MempoolPolicy { + /// Current block height + pub tip_height: absolute::Height, + /// Current median time past + pub tip_mtp: absolute::Time, +} + +impl MempoolPolicy { + /// Check that no input exceeds the maximum witness stack item count. + pub fn check_max_witness_stack(&self, inputs: &[Input]) -> Result<(), MempoolPolicyError> { + for input in inputs { + if !input.prev_txout().script_pubkey.is_p2wsh() { + continue; + } + + if let Some(count) = input.witness_item_count() { + if count.saturating_sub(1) > MAX_WITNESS_STACK_ITEMS { + return Err(MempoolPolicyError::MaxWitnessStackExceeded { + outpoint: input.prev_outpoint(), + }); + } + } + } + Ok(()) + } + + /// Check that the transaction weight does not exceed MAX_STANDARD_TX_WEIGHT (400,000 WU). + pub fn check_max_tx_weight( + &self, + weight: Weight, + version: Version, + ) -> Result<(), MempoolPolicyError> { + let limit = if version == Version(3) { + Weight::from_wu(MAX_TRUC_TX_WEIGHT) + } else { + Weight::from_wu(MAX_STANDARD_TX_WEIGHT as u64) + }; + + if weight > limit { + return Err(MempoolPolicyError::MaxWeightExceeded { weight }); + } + + Ok(()) + } + + /// Check that the transaction version is standard (version 1, 2, or 3). + /// + /// Version 3 (TRUC, BIP 431) is standard under Bitcoin Core v30+. + pub fn check_tx_version(&self, tx: &Transaction) -> Result<(), MempoolPolicyError> { + if !matches!(tx.version, Version::ONE | Version::TWO | Version(3)) { + return Err(MempoolPolicyError::UnsupportedVersion(tx.version)); + } + Ok(()) + } + + /// Check that the transaction's absolute locktime is satisfied by the current + /// chain tip height or median time past. + pub fn check_abs_locktime(&self, tx: &Transaction) -> Result<(), MempoolPolicyError> { + match tx.lock_time { + LockTime::Blocks(locktime) => { + if locktime > self.tip_height { + return Err(MempoolPolicyError::LockTimeNotMet(tx.lock_time)); + } + } + LockTime::Seconds(locktime) => { + if locktime >= self.tip_mtp { + return Err(MempoolPolicyError::LockTimeNotMet(tx.lock_time)); + } + } + } + Ok(()) + } + + /// Check that the transaction meets the minimum relay fee rate. + pub fn check_min_fee_relay( + &self, + fee: Amount, + expected_weight: Weight, + ) -> Result<(), MempoolPolicyError> { + // ceiling division: BIP 141 vsize = ceil(weight / 4) + let expected_vsize = expected_weight.to_wu().div_ceil(4); + + let required = Amount::from_sat(DEFAULT_MIN_RELAY_TX_FEE * expected_vsize / 1000); + + if fee < required { + return Err(MempoolPolicyError::MinRelayFeeNotMet { + fee, + required, + expected_vsize, + }); + } + Ok(()) + } + + /// Check that the transaction's non-witness size is at least 65 bytes. + pub fn check_min_non_witness_size(&self, tx: &Transaction) -> Result<(), MempoolPolicyError> { + let non_witness_size = tx.base_size(); + if non_witness_size < MIN_STANDARD_TX_NONWITNESS_SIZE as usize { + return Err(MempoolPolicyError::TxTooSmall { non_witness_size }); + } + Ok(()) + } + + /// Check that all inputs are currently spendable. + pub fn check_input_spendability(&self, inputs: &[Input]) -> Result<(), MempoolPolicyError> { + for input in inputs { + match input.is_spendable(self.tip_height, Some(self.tip_mtp)) { + Some(true) => continue, + Some(false) => { + return Err(MempoolPolicyError::InputNotSpendable { + outpoint: input.prev_outpoint(), + }) + } + None => { + return Err(MempoolPolicyError::InputSpendabilityUnknown { + outpoint: input.prev_outpoint(), + }) + } + } + } + Ok(()) + } + + /// Check that all inputs spend a standard script type. + pub fn check_input_script_type(&self, inputs: &[Input]) -> Result<(), MempoolPolicyError> { + for input in inputs { + if !is_standard_script(&input.prev_txout().script_pubkey) { + return Err(MempoolPolicyError::NonStandardInputScript { + outpoint: input.prev_outpoint(), + }); + } + } + Ok(()) + } + + /// Run all post-selection mempool policy checks against `selection` and `tx`. + /// + /// This is the second part of the two-layer policy split; the first part + /// lives in [`crate::SelectorParams::check_standardness`] and runs before coin selection. + pub fn check_all( + &self, + selection: &Selection, + tx: &Transaction, + ) -> Result<(), MempoolPolicyError> { + if selection.inputs.len() != tx.input.len() || selection.outputs.len() != tx.output.len() { + return Err(MempoolPolicyError::SelectionTxMismatch); + } + + if !selection + .inputs + .iter() + .zip(&tx.input) + .all(|(input, txin)| input.prev_outpoint() == txin.previous_output) + { + return Err(MempoolPolicyError::SelectionTxMismatch); + } + + if !selection + .outputs + .iter() + .zip(&tx.output) + .all(|(o, txo)| o.value == txo.value && o.script_pubkey() == txo.script_pubkey) + { + return Err(MempoolPolicyError::SelectionTxMismatch); + } + + self.check_tx_version(tx)?; + self.check_abs_locktime(tx)?; + self.check_min_non_witness_size(tx)?; + + // tx.weight() excludes witness data since the tx is unsigned. + // Add each input's satisfaction weight and the segwit marker/flag. + let satisfaction: Weight = selection + .inputs + .iter() + .map(|i| Weight::from_wu(i.satisfaction_weight())) + .sum(); + let segwit_overhead = if selection.inputs.iter().any(|i| i.is_segwit()) { + Weight::from_wu(2) + } else { + Weight::ZERO + }; + let expected_weight = tx.weight() + satisfaction + segwit_overhead; + + // Total fee: sum of input values minus sum of output values. + let input_value: Amount = selection + .inputs + .iter() + .map(|input| input.prev_txout().value) + .sum(); + let output_value: Amount = selection.outputs.iter().map(|output| output.value).sum(); + let fee = input_value + .checked_sub(output_value) + .ok_or(MempoolPolicyError::NegativeFee)?; + + self.check_max_tx_weight(expected_weight, tx.version)?; + self.check_input_spendability(&selection.inputs)?; + self.check_input_script_type(&selection.inputs)?; + self.check_max_witness_stack(&selection.inputs)?; + self.check_min_fee_relay(fee, expected_weight)?; + + Ok(()) + } +} + +/// Mempool policy validation errors. +#[derive(Debug)] +#[non_exhaustive] +pub enum MempoolPolicyError { + /// Transaction weight exceeds MAX_STANDARD_TX_WEIGHT (400,000 WU). + MaxWeightExceeded { + /// The actual weight of the transaction that exceeded the limit. + weight: Weight, + }, + /// Transaction version is not standard (must be 1, 2, or 3). + UnsupportedVersion(Version), + /// Transaction's absolute locktime is not satisfied by the current chain tip. + LockTimeNotMet(absolute::LockTime), + /// An input's witness stack exceeds 100 items. + MaxWitnessStackExceeded { + /// The outpoint whose witness stack exceeded the limit. + outpoint: OutPoint, + }, + /// Transaction fee is below the minimum relay fee rate. + MinRelayFeeNotMet { + /// The calculated fee for the transaction. + fee: Amount, + /// The virtual size of the transaction. + required: Amount, + /// The minimum relay feerate in satoshis per kilobyte (sat/kvB) that the transaction failed to meet. + expected_vsize: u64, + }, + /// Transaction's non-witness size is below 65 bytes. + TxTooSmall { + /// The non-witness size of the transaction. + non_witness_size: usize, + }, + /// Input is definitively not yet spendable (immature coinbase or unmet timelock). + InputNotSpendable { + /// The outpoint of the input that is not yet spendable. + outpoint: OutPoint, + }, + /// Input spends non-standard script type. + NonStandardInputScript { + /// The outpoint of the input that spends a non-standard script type. + outpoint: OutPoint, + }, + /// Fee is negative (outputs exceed inputs). + NegativeFee, + + /// Input spendability could not be determined. Currently this happens when an input + /// has a time-based relative timelock and is missing the `prev_mtp` data needed to evaluate it. + InputSpendabilityUnknown { + /// The outpoint whose spendability could not be determined. + outpoint: OutPoint, + }, + /// The provided `Selection` and `Transaction` do not correspond. Their + /// input counts differ, or their inputs reference different outpoints. + SelectionTxMismatch, +} + +impl core::fmt::Display for MempoolPolicyError { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + match self { + Self::MaxWeightExceeded { weight } => { + write!(f, "transaction weight {weight} exceeds the standard limit of {MAX_STANDARD_TX_WEIGHT} WU") + } + Self::UnsupportedVersion(version) => { + write!( + f, + "transaction version {version} is not standard (only 1, 2 and 3 are accepted)" + ) + } + Self::LockTimeNotMet(lock_time) => { + write!(f, "transaction locktime {lock_time} is not yet satisfied by the current chain tip") + } + Self::MaxWitnessStackExceeded { outpoint } => { + write!( + f, "input {outpoint} witness stack exceeds the limit of {MAX_WITNESS_STACK_ITEMS} items" + ) + } + Self::MinRelayFeeNotMet { + fee, + required, + expected_vsize, + } => { + write!( + f, + "fee {fee} for {expected_vsize} vB is below the required minimum of {required}" + ) + } + Self::TxTooSmall { non_witness_size } => { + write!(f, "non-witness size {non_witness_size} bytes is below the minimum of {MIN_STANDARD_TX_NONWITNESS_SIZE} bytes") + } + Self::InputNotSpendable { outpoint } => { + write!(f, "input {outpoint} is not yet spendable") + } + Self::NonStandardInputScript { outpoint } => { + write!(f, "input {outpoint} spends a non-standard script type") + } + Self::NegativeFee => { + write!(f, "total output value exceeds total input value") + } + Self::InputSpendabilityUnknown { outpoint } => { + write!( + f, + "input {outpoint} spendability is unknown (missing prev_mtp for time-based relative timelock evaluation)" + ) + } + Self::SelectionTxMismatch => { + write!( + f, + "the provided Selection and Transaction do not correspond" + ) + } + } + } +} + +#[cfg(feature = "std")] +impl std::error::Error for MempoolPolicyError {} From c98521bee28ae92450cb106e7688d86913ceb553 Mon Sep 17 00:00:00 2001 From: Abiodun Date: Tue, 14 Apr 2026 15:45:34 +0100 Subject: [PATCH 3/3] test: add test for standardness and mempool policy checks --- src/lib.rs | 2 + src/policy.rs | 196 +++++++++++++++++++++++++++++++++++++++++++++- src/selector.rs | 125 +++++++++++++++++++++++++++++ src/test_utils.rs | 136 ++++++++++++++++++++++++++++++++ 4 files changed, 458 insertions(+), 1 deletion(-) create mode 100644 src/test_utils.rs diff --git a/src/lib.rs b/src/lib.rs index 9fac2df..c54d960 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -18,6 +18,8 @@ mod rbf; mod selection; mod selector; mod signer; +#[cfg(test)] +pub(crate) mod test_utils; mod utils; pub use canonical_unspents::*; diff --git a/src/policy.rs b/src/policy.rs index 4b63c64..7fbfeb1 100644 --- a/src/policy.rs +++ b/src/policy.rs @@ -185,7 +185,7 @@ impl MempoolPolicy { { return Err(MempoolPolicyError::SelectionTxMismatch); } - + if !selection .outputs .iter() @@ -350,3 +350,197 @@ impl core::fmt::Display for MempoolPolicyError { #[cfg(feature = "std")] impl std::error::Error for MempoolPolicyError {} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{test_utils::*, Output}; + use alloc::vec::Vec; + use bitcoin::{transaction::Version, Amount, ScriptBuf, Transaction, TxOut}; + + fn default_policy() -> MempoolPolicy { + MempoolPolicy { + tip_height: absolute::Height::from_consensus(3_000).unwrap(), + tip_mtp: absolute::Time::from_consensus(500_001_000).unwrap(), + } + } + + #[test] + fn test_tx_version() { + let policy = default_policy(); + let input = setup_test_input(2_000).unwrap(); + let output = create_output(p2tr_script(), 9_000); + let (selection, mut tx) = build_selection_with_tx(&[input], &[output]); + + // Default version is 2, which is standard. + assert!(policy.check_all(&selection, &tx).is_ok()); + + // Test version 1, which is also standard. + tx.version = Version::ONE; + assert!(policy.check_all(&selection, &tx).is_ok()); + + // Version 3 (TRUC) is standard under v30+. + tx.version = Version(3); + assert!(policy.check_all(&selection, &tx).is_ok()); + + // Test version 4, which is non-standard. + tx.version = Version(4); + assert!(matches!( + policy.check_all(&selection, &tx), + Err(MempoolPolicyError::UnsupportedVersion(_)) + )); + } + + #[test] + fn test_tx_locktime() { + let policy = default_policy(); + let input = setup_test_input(2_000).unwrap(); + let output = create_output(p2tr_script(), 9_000); + let (selection, mut tx) = build_selection_with_tx(&[input], &[output]); + + // Locktime exactly equal to the tip height. + tx.lock_time = absolute::LockTime::from_consensus(3_000); + assert!(policy.check_all(&selection, &tx).is_ok()); + + // Locktime below the tip height. + tx.lock_time = absolute::LockTime::from_consensus(2_500); + assert!(policy.check_all(&selection, &tx).is_ok()); + + // Locktime above the tip height. + tx.lock_time = absolute::LockTime::from_consensus(3_001); + assert!(matches!( + policy.check_all(&selection, &tx), + Err(MempoolPolicyError::LockTimeNotMet(_)) + )); + + // Locktime one second below the tip MTP. + tx.lock_time = absolute::LockTime::from_consensus(500_000_999); + assert!(policy.check_all(&selection, &tx).is_ok()); + + // Locktime exactly equal to the tip MTP. + tx.lock_time = absolute::LockTime::from_consensus(500_001_000); + assert!(matches!( + policy.check_all(&selection, &tx), + Err(MempoolPolicyError::LockTimeNotMet(_)) + )); + + // Locktime above the tip MTP. + tx.lock_time = absolute::LockTime::from_consensus(500_002_000); + assert!(matches!( + policy.check_all(&selection, &tx), + Err(MempoolPolicyError::LockTimeNotMet(_)) + )); + } + + #[test] + fn test_max_tx_weight() { + let policy = default_policy(); + + // A normal transaction with 1 input and 1 output. + let input = setup_test_input(2_000).unwrap(); + let output = create_output(p2tr_script(), 9_000); + let (selection, tx) = build_selection_with_tx(core::slice::from_ref(&input), &[output]); + assert!(policy.check_all(&selection, &tx).is_ok()); + + // Heavy transaction with excess weight. + let outputs_with_excess_weight: Vec = (0..2_350) + .map(|_| create_output(p2tr_script(), 1_000)) + .collect(); + + let (_, heavy_tx) = + build_selection_with_tx(&[input], outputs_with_excess_weight.as_slice()); + + assert!(heavy_tx.weight().to_wu() > MAX_STANDARD_TX_WEIGHT as u64); + assert!(matches!( + policy.check_max_tx_weight(heavy_tx.weight(), heavy_tx.version), + Err(MempoolPolicyError::MaxWeightExceeded { .. }) + )); + } + + #[test] + fn test_tx_min_non_witness_size() { + let policy = default_policy(); + let input = setup_test_input(2_000).unwrap(); + let output = create_output(p2tr_script(), 9_000); + + // Transaction with 1 input and 1 output. + let (selection, tx) = build_selection_with_tx(&[input], &[output]); + assert!(policy.check_all(&selection, &tx).is_ok()); + + // Transaction with no inputs and 1 output. + let tx_below_min_non_witness_size = Transaction { + version: Version::TWO, + lock_time: absolute::LockTime::ZERO, + input: vec![], + output: vec![TxOut { + script_pubkey: ScriptBuf::new(), + value: Amount::ZERO, + }], + }; + let empty_selection = Selection { + inputs: vec![], + outputs: vec![Output::with_script(ScriptBuf::new(), Amount::ZERO)], + }; + assert!( + tx_below_min_non_witness_size.base_size() < MIN_STANDARD_TX_NONWITNESS_SIZE as usize + ); + assert!(matches!( + policy.check_all(&empty_selection, &tx_below_min_non_witness_size), + Err(MempoolPolicyError::TxTooSmall { .. }) + )); + } + + #[test] + fn test_min_fee_relay() { + let policy = default_policy(); + + // Sufficient fee passes. + let input = setup_test_input(2_000).unwrap(); + let output = create_output(p2tr_script(), 9_000); + + let (selection, tx) = build_selection_with_tx(&[input], &[output]); + assert!(policy.check_all(&selection, &tx).is_ok()); + + // Fee below the 1 sat/vB minimum is rejected. + let input_with_insufficient_fee = setup_test_input(2_000).unwrap(); + let output_with_insufficient_fee = create_output(p2tr_script(), 9_999); + + let (selection_with_insufficient_fee, tx_with_insufficient_fee) = build_selection_with_tx( + &[input_with_insufficient_fee], + &[output_with_insufficient_fee], + ); + assert!(matches!( + policy.check_all(&selection_with_insufficient_fee, &tx_with_insufficient_fee), + Err(MempoolPolicyError::MinRelayFeeNotMet { .. }) + )); + } + + #[test] + fn test_max_witness_stack() { + let policy = default_policy(); + let input = setup_test_input(2_000).unwrap(); + + assert!(policy.check_max_witness_stack(&[input]).is_ok()); + } + + #[test] + fn test_input_spendability() { + let policy = default_policy(); + // A confirmed input + let input = setup_test_input(2_000).unwrap(); + assert!(policy.check_input_spendability(&[input]).is_ok()); + + // An immature input + let input_with_immature_coinbase = setup_test_input(2_950).unwrap(); + assert!(policy + .check_input_spendability(&[input_with_immature_coinbase]) + .is_err()); + } + + #[test] + fn test_input_script_type() { + let policy = default_policy(); + let input = setup_test_input(2_000).unwrap(); + assert!(policy.check_input_script_type(&[input]).is_ok()); + } +} diff --git a/src/selector.rs b/src/selector.rs index 6400fa9..02f3366 100644 --- a/src/selector.rs +++ b/src/selector.rs @@ -687,3 +687,128 @@ impl<'c> Selector<'c> { }) } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::test_utils::*; + use bitcoin::Amount; + + fn test_builder() -> SelectorParamsBuilder { + SelectorParams::builder( + FeeRate::from_sat_per_vb_unchecked(1), + ChangeScript::from_script(p2tr_script(), Weight::from_wu(70)), + ) + } + + #[test] + fn test_new_skips_validation() { + // The unvalidated selection. + let params = SelectorParams::new( + FeeRate::from_sat_per_vb_unchecked(1), + vec![create_output(p2tr_script(), 1)], // dust + ChangeScript::from_script(p2tr_script(), Weight::from_wu(70)), + ); + // Construction succeeds; explicit validation would fail. + assert!(matches!( + params.check_standardness(), + Err(SelectorParamsError::DustOutput { .. }) + )); + } + + #[test] + fn test_dust_output() { + let script = p2tr_script(); + let dust_limit = script.minimal_non_dust(); + let below_dust = dust_limit.to_sat() - 1; + + // Output exactly at the minimum non-dust value. + assert!(test_builder() + .add_output(create_output(script.clone(), dust_limit.to_sat())) + .build() + .is_ok()); + + // OP_RETURN outputs are exempt from the dust check. + assert!(test_builder() + .add_output(create_output(op_return_script(b"test data"), 0)) + .build() + .is_ok()); + + // Below the dust threshold reports the actual and required values. + match test_builder() + .add_output(create_output(script, below_dust)) + .build() + { + Err(SelectorParamsError::DustOutput { actual, required }) => { + assert_eq!(actual, Amount::from_sat(below_dust)); + assert_eq!(required, dust_limit); + } + other => panic!("expected DustOutput error, got {:?}", other), + } + } + + #[test] + fn test_op_return_policy() { + // A single zero-value OP_RETURN. + assert!(test_builder() + .add_output(create_output(op_return_script(b"first message"), 0)) + .build() + .is_ok()); + + // OP_RETURN with non-zero value is rejected. + assert!(matches!( + test_builder() + .add_output(create_output(op_return_script(b"data"), 1)) + .build(), + Err(SelectorParamsError::OpReturnWithValue) + )); + + // A single large OP_RETURN well under the cap passes. + let large_but_ok = op_return_script(&vec![0xab; 50_000]); + assert!(test_builder() + .add_output(create_output(large_but_ok, 0)) + .build() + .is_ok()); + + // Two OP_RETURNs that individually fit but together exceed the + // aggregate cap are rejected. + let half_one = op_return_script_large(&vec![0xab; 60_000]); + let half_two = op_return_script_large(&vec![0xcd; 60_000]); + match test_builder() + .add_outputs(vec![create_output(half_one, 0), create_output(half_two, 0)]) + .build() + { + Err(SelectorParamsError::OpReturnTooLarge { actual, max }) => { + assert!(actual > max); + assert_eq!(max, MAX_OP_RETURN_BYTES); + } + other => panic!("expected OpReturnTooLarge, got {:?}", other), + } + + // A single OP_RETURN coexists with regular outputs. + assert!(test_builder() + .add_outputs(vec![ + create_output(p2tr_script(), 50_000), + create_output(p2tr_script(), 30_000), + create_output(op_return_script(b"memo"), 0), + ]) + .build() + .is_ok()); + } + #[test] + fn test_output_script_type() { + // Standard P2TR output passes. + assert!(test_builder() + .add_output(create_output(p2tr_script(), 10_000)) + .build() + .is_ok()); + + // Non-standard script is rejected. + assert!(matches!( + test_builder() + .add_output(create_output(non_standard_script(), 10_000)) + .build(), + Err(SelectorParamsError::NonStandardScriptType) + )); + } +} diff --git a/src/test_utils.rs b/src/test_utils.rs new file mode 100644 index 0000000..75e499d --- /dev/null +++ b/src/test_utils.rs @@ -0,0 +1,136 @@ +/// Shared test utilities for `bdk-tx` tests. +/// +/// Provides helper functions for creating test inputs, outputs, and transactions +/// used across multiple test modules. +use bitcoin::{ + absolute::{self, Time}, + opcodes::all::OP_RETURN, + script::Builder, + secp256k1::Secp256k1, + transaction, Amount, ScriptBuf, Transaction, TxIn, TxOut, +}; +use miniscript::{plan::Assets, Descriptor, DescriptorPublicKey}; + +use crate::{ConfirmationStatus, Input, Output, Selection}; +use alloc::vec::Vec; + +pub(crate) const TEST_DESCRIPTOR: &str = "tr([83737d5e/86h/1h/0h]tpubDDR5GgtoxS8fJyjjvdahN4VzV5DV6jtbcyvVXhEKq2XtpxjxBXmxH3r8QrNbQqHg4bJM1EGkxi7Pjfkgnui9jQWqS7kxHvX6rhUeriLDKxz/0/*)"; +pub(crate) const TEST_DESCRIPTOR_PK: &str = "[83737d5e/86h/1h/0h]tpubDDR5GgtoxS8fJyjjvdahN4VzV5DV6jtbcyvVXhEKq2XtpxjxBXmxH3r8QrNbQqHg4bJM1EGkxi7Pjfkgnui9jQWqS7kxHvX6rhUeriLDKxz/0/*"; + +/// Create a standard Taproot test input confirmed at the given height. +pub(crate) fn setup_test_input(confirmation_height: u32) -> anyhow::Result { + let secp = Secp256k1::new(); + let desc = Descriptor::parse_descriptor(&secp, TEST_DESCRIPTOR) + .unwrap() + .0; + let def_desc = desc.at_derivation_index(0).unwrap(); + let script_pubkey = def_desc.script_pubkey(); + let desc_pk: DescriptorPublicKey = TEST_DESCRIPTOR_PK.parse()?; + let assets = Assets::new().add(desc_pk); + let plan = def_desc.plan(&assets).expect("failed to create plan"); + + let prev_tx = Transaction { + version: transaction::Version::TWO, + lock_time: absolute::LockTime::ZERO, + input: vec![TxIn::default()], + output: vec![TxOut { + script_pubkey, + value: Amount::from_sat(10_000), + }], + }; + + let status = ConfirmationStatus { + height: absolute::Height::from_consensus(confirmation_height)?, + prev_mtp: Some(Time::from_consensus(500_000_000)?), + }; + + let input = Input::from_prev_tx(plan, prev_tx, 0, Some(status))?; + Ok(input) +} + +/// Create a simple output with the given script and value. +pub(crate) fn create_output(script: ScriptBuf, value: u64) -> Output { + Output::with_script(script, Amount::from_sat(value)) +} + +/// Create a standard P2TR output script (empty, for test purposes). +pub(crate) fn p2tr_script() -> ScriptBuf { + let secp = Secp256k1::new(); + let desc = Descriptor::parse_descriptor(&secp, TEST_DESCRIPTOR) + .unwrap() + .0; + desc.at_derivation_index(0).unwrap().script_pubkey() +} + +/// Create an OP_RETURN script with the given data. +pub(crate) fn op_return_script(data: &[u8]) -> ScriptBuf { + let push_bytes = bitcoin::script::PushBytesBuf::try_from(data.to_vec()) + .expect("data must be valid push bytes"); + + Builder::new() + .push_opcode(OP_RETURN) + .push_slice(push_bytes) + .into_script() +} + +/// Create an OP_RETURN script with arbitrary-sized data +pub(crate) fn op_return_script_large(data: &[u8]) -> ScriptBuf { + let mut bytes = Vec::with_capacity(data.len() + 6); + bytes.push(bitcoin::opcodes::all::OP_RETURN.to_u8()); + + // Choose the minimal push opcode for the length. + match data.len() { + 0..=75 => { + bytes.push(data.len() as u8); + } + 76..=255 => { + bytes.push(0x4c); // OP_PUSHDATA1 + bytes.push(data.len() as u8); + } + 256..=65_535 => { + bytes.push(0x4d); // OP_PUSHDATA2 + bytes.extend_from_slice(&(data.len() as u16).to_le_bytes()); + } + _ => { + bytes.push(0x4e); // OP_PUSHDATA4 + bytes.extend_from_slice(&(data.len() as u32).to_le_bytes()); + } + } + + bytes.extend_from_slice(data); + ScriptBuf::from_bytes(bytes) +} + +/// Create a non-standard script for testing. +pub(crate) fn non_standard_script() -> ScriptBuf { + Builder::new() + .push_opcode(bitcoin::opcodes::all::OP_NOP) + .push_opcode(bitcoin::opcodes::all::OP_NOP) + .into_script() +} + +/// Build a test transaction and Selection from the given inputs and outputs. +pub(crate) fn build_selection_with_tx( + inputs: &[Input], + outputs: &[Output], +) -> (Selection, Transaction) { + let selection = Selection { + inputs: inputs.to_vec(), + outputs: outputs.to_vec(), + }; + + let tx = Transaction { + version: transaction::Version::TWO, + lock_time: absolute::LockTime::ZERO, + input: inputs + .iter() + .map(|input| TxIn { + previous_output: input.prev_outpoint(), + ..Default::default() + }) + .collect(), + output: outputs.iter().map(|output| output.txout()).collect(), + }; + + (selection, tx) +}