diff --git a/packages/wasm-utxo/js/fixedScriptWallet/BitGoPsbt.ts b/packages/wasm-utxo/js/fixedScriptWallet/BitGoPsbt.ts index 03d025c5858..4f432f3a6bc 100644 --- a/packages/wasm-utxo/js/fixedScriptWallet/BitGoPsbt.ts +++ b/packages/wasm-utxo/js/fixedScriptWallet/BitGoPsbt.ts @@ -959,12 +959,15 @@ export class BitGoPsbt extends PsbtBase implements IPsbtWithAddre /** * Extract the final transaction from a finalized PSBT * + * @param maxFeeRate Optional maximum fee rate in **sat/vB**. `Infinity` skips + * the absurd-fee check; `undefined` uses rust-bitcoin's default check. + * Callers holding sat/kB thresholds must divide by 1000 before passing. * @returns The extracted transaction instance * @throws Error if the PSBT is not fully finalized or extraction fails */ - extractTransaction(): ITransaction { + extractTransaction(maxFeeRate?: number): ITransaction { const networkType = this._wasm.get_network_type(); - const wasm: unknown = this._wasm.extract_transaction(); + const wasm: unknown = this._wasm.extract_transaction(maxFeeRate); switch (networkType) { case "dash": diff --git a/packages/wasm-utxo/js/fixedScriptWallet/ZcashBitGoPsbt.ts b/packages/wasm-utxo/js/fixedScriptWallet/ZcashBitGoPsbt.ts index 31291680708..a43efe751e7 100644 --- a/packages/wasm-utxo/js/fixedScriptWallet/ZcashBitGoPsbt.ts +++ b/packages/wasm-utxo/js/fixedScriptWallet/ZcashBitGoPsbt.ts @@ -283,10 +283,13 @@ export class ZcashBitGoPsbt extends BitGoPsbt { /** * Extract the final Zcash transaction from a finalized PSBT * + * @param maxFeeRate Optional maximum fee rate in **sat/vB**. `Infinity` skips + * the absurd-fee check; `undefined` uses rust-bitcoin's default check. + * Callers holding sat/kB thresholds must divide by 1000 before passing. * @returns The extracted Zcash transaction instance * @throws Error if the PSBT is not fully finalized or extraction fails */ - override extractTransaction(): ZcashTransaction { - return ZcashTransaction.fromWasm(this.wasm.extract_zcash_transaction()); + override extractTransaction(maxFeeRate?: number): ZcashTransaction { + return ZcashTransaction.fromWasm(this.wasm.extract_zcash_transaction(maxFeeRate)); } } diff --git a/packages/wasm-utxo/src/fixed_script_wallet/bitgo_psbt/mod.rs b/packages/wasm-utxo/src/fixed_script_wallet/bitgo_psbt/mod.rs index d5c22c3fe24..d774c80b4f0 100644 --- a/packages/wasm-utxo/src/fixed_script_wallet/bitgo_psbt/mod.rs +++ b/packages/wasm-utxo/src/fixed_script_wallet/bitgo_psbt/mod.rs @@ -16,7 +16,7 @@ pub mod zcash_psbt; use crate::Network; pub use dash_psbt::DashBitGoPsbt; -use miniscript::bitcoin::{psbt::Psbt, secp256k1, CompressedPublicKey, Txid}; +use miniscript::bitcoin::{psbt::Psbt, secp256k1, CompressedPublicKey, FeeRate, Txid}; pub use propkv::{ find_kv, get_zec_consensus_branch_id, BitGoKeyValue, ProprietaryKeySubtype, WasmUtxoVersionInfo, BITGO, @@ -339,6 +339,45 @@ pub(crate) fn make_psbt_with_xpubs( psbt } +/// Fee-rate policy applied during PSBT extraction. +/// +/// Controls how rust-bitcoin's absurd-fee-rate guard behaves when extracting a +/// finalized transaction. This is pure plumbing — it carries no per-coin +/// policy; callers that want a coin-aware default must resolve it themselves +/// and pass [`ExtractFeePolicy::Limited`] or [`ExtractFeePolicy::Unchecked`]. +/// +/// All fee rates here are in **sat/vB** (rust-bitcoin's `FeeRate` unit). The +/// wasm/JS boundary also uses **sat/vB**; callers holding sat/kB thresholds +/// must convert (÷ 1000) before calling. See `fee_policy_from_js`. +#[derive(Debug, Clone, Copy)] +pub enum ExtractFeePolicy { + /// Use rust-bitcoin's stock absurd-fee-rate check (`Psbt::extract_tx`). + Default, + /// Skip the absurd-fee-rate check entirely (`Psbt::extract_tx_unchecked_fee_rate`). + Unchecked, + /// Enforce a maximum fee rate in **sat/vB** + /// (`Psbt::extract_tx_with_fee_rate_limit`). + Limited(FeeRate), +} + +/// Extract a `Transaction` from a rust-bitcoin `Psbt` applying an +/// [`ExtractFeePolicy`]. Shared by the `BitcoinLike` and `Dash` branches, +/// which both hold an inner `Psbt`. +fn extract_inner_with_fee_policy( + psbt: Psbt, + policy: ExtractFeePolicy, +) -> Result { + match policy { + ExtractFeePolicy::Default => psbt + .extract_tx() + .map_err(|e| format!("Failed to extract transaction: {}", e)), + ExtractFeePolicy::Unchecked => Ok(psbt.extract_tx_unchecked_fee_rate()), + ExtractFeePolicy::Limited(max_fee_rate_sat_per_vb) => psbt + .extract_tx_with_fee_rate_limit(max_fee_rate_sat_per_vb) + .map_err(|e| format!("Failed to extract transaction: {}", e)), + } +} + impl BitGoPsbt { /// Deserialize a PSBT from bytes, using network-specific logic pub fn deserialize(psbt_bytes: &[u8], network: Network) -> Result { @@ -1371,16 +1410,30 @@ impl BitGoPsbt { /// * `Ok(Vec)` - The serialized transaction bytes /// * `Err(String)` - If transaction extraction fails pub fn extract_tx(self) -> Result, String> { + self.extract_tx_with_fee_policy(ExtractFeePolicy::Default) + } + + /// Extract the fully-signed transaction from a finalized PSBT with an + /// explicit fee-rate [`policy`][ExtractFeePolicy]. + /// + /// This method consumes the PSBT since the underlying `extract_tx()` requires ownership. + /// + /// # Requirements + /// All inputs must be finalized before calling this method. + /// + /// # Returns + /// * `Ok(Vec)` - The serialized transaction bytes + /// * `Err(String)` - If transaction extraction fails (including absurd-fee + /// rejection when a [`Limited`][ExtractFeePolicy::Limited] policy is enforced) + pub fn extract_tx_with_fee_policy(self, policy: ExtractFeePolicy) -> Result, String> { use miniscript::bitcoin::consensus::serialize; match self { BitGoPsbt::Zcash(zcash_psbt, _) => zcash_psbt - .extract_tx() + .extract_tx_with_fee_policy(policy) .map_err(|e| format!("Failed to extract transaction: {}", e)), BitGoPsbt::BitcoinLike(psbt, _) | BitGoPsbt::Dash(DashBitGoPsbt { psbt, .. }, _) => { - let tx = psbt - .extract_tx() - .map_err(|e| format!("Failed to extract transaction: {}", e))?; + let tx = extract_inner_with_fee_policy(psbt, policy)?; Ok(serialize(&tx)) } } @@ -1392,10 +1445,21 @@ impl BitGoPsbt { /// * `Ok(Transaction)` - The extracted transaction /// * `Err(String)` - If not BitcoinLike or extraction fails pub fn extract_bitcoin_tx(self) -> Result { + self.extract_bitcoin_tx_with_fee_policy(ExtractFeePolicy::Default) + } + + /// Extract the Bitcoin transaction directly (for BitcoinLike networks only) + /// with an explicit fee-rate [`policy`][ExtractFeePolicy]. + /// + /// # Returns + /// * `Ok(Transaction)` - The extracted transaction + /// * `Err(String)` - If not BitcoinLike or extraction fails + pub fn extract_bitcoin_tx_with_fee_policy( + self, + policy: ExtractFeePolicy, + ) -> Result { match self { - BitGoPsbt::BitcoinLike(psbt, _) => psbt - .extract_tx() - .map_err(|e| format!("Failed to extract transaction: {}", e)), + BitGoPsbt::BitcoinLike(psbt, _) => extract_inner_with_fee_policy(psbt, policy), _ => Err("extract_bitcoin_tx only supported for BitcoinLike networks".to_string()), } } @@ -1406,13 +1470,23 @@ impl BitGoPsbt { /// * `Ok(DashTransactionParts)` - The extracted transaction parts /// * `Err(String)` - If not Dash or extraction fails pub fn extract_dash_tx(self) -> Result { + self.extract_dash_tx_with_fee_policy(ExtractFeePolicy::Default) + } + + /// Extract the Dash transaction parts directly with an explicit fee-rate + /// [`policy`][ExtractFeePolicy]. + /// + /// # Returns + /// * `Ok(DashTransactionParts)` - The extracted transaction parts + /// * `Err(String)` - If not Dash or extraction fails + pub fn extract_dash_tx_with_fee_policy( + self, + policy: ExtractFeePolicy, + ) -> Result { use miniscript::bitcoin::consensus::serialize; match self { BitGoPsbt::Dash(dash_psbt, _) => { - let tx = dash_psbt - .psbt - .extract_tx() - .map_err(|e| format!("Failed to extract transaction: {}", e))?; + let tx = extract_inner_with_fee_policy(dash_psbt.psbt, policy)?; let tx_bytes = serialize(&tx); crate::dash::transaction::decode_dash_transaction_parts(&tx_bytes) .map_err(|e| format!("Failed to decode Dash transaction: {}", e)) @@ -1428,11 +1502,24 @@ impl BitGoPsbt { /// * `Err(String)` - If not Zcash or extraction fails pub fn extract_zcash_tx( self, + ) -> Result { + self.extract_zcash_tx_with_fee_policy(ExtractFeePolicy::Default) + } + + /// Extract the Zcash transaction parts directly with an explicit fee-rate + /// [`policy`][ExtractFeePolicy]. + /// + /// # Returns + /// * `Ok(ZcashTransactionParts)` - The extracted transaction parts + /// * `Err(String)` - If not Zcash or extraction fails + pub fn extract_zcash_tx_with_fee_policy( + self, + policy: ExtractFeePolicy, ) -> Result { match self { BitGoPsbt::Zcash(zcash_psbt, _) => { let bytes = zcash_psbt - .extract_tx() + .extract_tx_with_fee_policy(policy) .map_err(|e| format!("Failed to extract transaction: {}", e))?; crate::zcash::transaction::decode_zcash_transaction_parts(&bytes) .map_err(|e| format!("Failed to decode Zcash transaction: {}", e)) @@ -4119,6 +4206,67 @@ mod tests { ); }); + /// `extract_tx_with_fee_policy` must produce byte-identical output to the + /// default `extract_tx()` for a normal-fee PSBT across all three policies + /// (`Default`, `Unchecked`, `Limited`). This pins the param plumbing added + /// in this change without depending on per-coin fee policy. + #[test] + fn test_extract_tx_with_fee_policy_matches_default() { + use crate::fixed_script_wallet::test_utils::fixtures::{ + self, FixtureNamespace, SignatureState, TxFormat, + }; + use crate::Network; + + let network = Network::Bitcoin; + let fixture = fixtures::load_psbt_fixture_with_format_and_namespace( + network.to_utxolib_name(), + SignatureState::Fullsigned, + TxFormat::Psbt, + FixtureNamespace::UtxolibCompat, + ) + .expect("Failed to load fixture"); + let mut bitgo_psbt = fixture + .to_bitgo_psbt(network) + .expect("Failed to convert to BitGo PSBT"); + + let secp = crate::bitcoin::secp256k1::Secp256k1::new(); + bitgo_psbt + .finalize_mut(&secp) + .expect("Failed to finalize PSBT"); + + let default_bytes = bitgo_psbt.clone().extract_tx().expect("default extract"); + + // A high sat/vB ceiling that no normal-fee tx would trip. + let high_limit_sat_per_vb = ExtractFeePolicy::Limited( + miniscript::bitcoin::FeeRate::from_sat_per_vb_unchecked(1_000_000_000), + ); + + assert_eq!( + bitgo_psbt + .clone() + .extract_tx_with_fee_policy(ExtractFeePolicy::Default) + .expect("Default policy"), + default_bytes, + "Default policy must match extract_tx()" + ); + assert_eq!( + bitgo_psbt + .clone() + .extract_tx_with_fee_policy(ExtractFeePolicy::Unchecked) + .expect("Unchecked policy"), + default_bytes, + "Unchecked policy must match extract_tx() on a normal-fee PSBT" + ); + assert_eq!( + bitgo_psbt + .clone() + .extract_tx_with_fee_policy(high_limit_sat_per_vb) + .expect("Limited policy"), + default_bytes, + "Limited policy with a high sat/vB ceiling must match extract_tx()" + ); + } + /// Test extract_half_signed_legacy_tx for p2ms-based script types fn test_extract_half_signed_legacy_tx_for_script_type( network: Network, diff --git a/packages/wasm-utxo/src/fixed_script_wallet/bitgo_psbt/zcash_psbt.rs b/packages/wasm-utxo/src/fixed_script_wallet/bitgo_psbt/zcash_psbt.rs index 5158b607d7c..020984a00a0 100644 --- a/packages/wasm-utxo/src/fixed_script_wallet/bitgo_psbt/zcash_psbt.rs +++ b/packages/wasm-utxo/src/fixed_script_wallet/bitgo_psbt/zcash_psbt.rs @@ -161,6 +161,17 @@ impl ZcashBitGoPsbt { /// /// This method consumes the PSBT to avoid cloning. pub fn extract_tx(self) -> Result, super::DeserializeError> { + self.extract_tx_with_fee_policy(super::ExtractFeePolicy::Default) + } + + /// Extract the finalized Zcash transaction bytes from the PSBT with an + /// explicit fee-rate [`policy`][super::ExtractFeePolicy]. + /// + /// This method consumes the PSBT to avoid cloning. + pub fn extract_tx_with_fee_policy( + self, + policy: super::ExtractFeePolicy, + ) -> Result, super::DeserializeError> { use miniscript::bitcoin::psbt::ExtractTxError; // Capture Zcash-specific fields before consuming psbt @@ -170,7 +181,7 @@ impl ZcashBitGoPsbt { let expiry_height = self.expiry_height.unwrap_or(0); let sapling_fields = self.sapling_fields; - let tx = self.psbt.extract_tx().map_err(|e| match e { + let map_extract_err = |e: ExtractTxError| match e { ExtractTxError::AbsurdFeeRate { .. } => { super::DeserializeError::Network(format!("Absurd fee rate: {}", e)) } @@ -181,7 +192,16 @@ impl ZcashBitGoPsbt { super::DeserializeError::Network(format!("Sending too much: {}", e)) } _ => super::DeserializeError::Network(format!("Failed to extract transaction: {}", e)), - })?; + }; + + let tx = match policy { + super::ExtractFeePolicy::Default => self.psbt.extract_tx().map_err(map_extract_err)?, + super::ExtractFeePolicy::Unchecked => self.psbt.extract_tx_unchecked_fee_rate(), + super::ExtractFeePolicy::Limited(max_fee_rate_sat_per_vb) => self + .psbt + .extract_tx_with_fee_rate_limit(max_fee_rate_sat_per_vb) + .map_err(map_extract_err)?, + }; let parts = crate::zcash::transaction::ZcashTransactionParts { transaction: tx, diff --git a/packages/wasm-utxo/src/wasm/fixed_script_wallet/mod.rs b/packages/wasm-utxo/src/wasm/fixed_script_wallet/mod.rs index ef5c987c23d..3c57d01f3a9 100644 --- a/packages/wasm-utxo/src/wasm/fixed_script_wallet/mod.rs +++ b/packages/wasm-utxo/src/wasm/fixed_script_wallet/mod.rs @@ -9,6 +9,7 @@ use wasm_bindgen::JsValue; use crate::address::networks::AddressFormat; use crate::error::WasmUtxoError; +use crate::fixed_script_wallet::bitgo_psbt::ExtractFeePolicy; use crate::fixed_script_wallet::wallet_scripts::{chain_index_path, OutputScriptType}; use crate::fixed_script_wallet::{Chain, Scope, WalletScripts}; use crate::utxolib_compat::UtxolibNetwork; @@ -22,8 +23,7 @@ use crate::wasm::wallet_keys::WasmRootWalletKeys; /// Parse a network from a string that can be either a utxolib name or a coin name fn parse_network(network_str: &str) -> Result { - crate::networks::Network::from_utxolib_name(network_str) - .or_else(|| crate::networks::Network::from_coin_name(network_str)) + crate::networks::Network::from_utxolib_name(network_str) .or_else(|| crate::networks::Network::from_coin_name(network_str)) .ok_or_else(|| { WasmUtxoError::new(&format!( "Unknown network '{}'. Expected a utxolib name (e.g., 'bitcoin', 'testnet') or coin name (e.g., 'btc', 'tbtc')", @@ -32,6 +32,32 @@ fn parse_network(network_str: &str) -> Result) -> ExtractFeePolicy { + use miniscript::bitcoin::FeeRate; + + match max_fee_rate_sat_per_vb { + None => ExtractFeePolicy::Default, + Some(sat_per_vb) if sat_per_vb.is_infinite() => ExtractFeePolicy::Unchecked, + Some(sat_per_vb) if sat_per_vb.is_finite() && sat_per_vb >= 0.0 => { + ExtractFeePolicy::Limited(FeeRate::from_sat_per_vb_unchecked(sat_per_vb as u64)) + } + _ => ExtractFeePolicy::Default, + } +} + #[wasm_bindgen] pub struct FixedScriptWalletNamespace; @@ -1857,14 +1883,18 @@ impl BitGoPsbt { /// # Returns /// - `Ok(JsValue)` containing the WASM transaction instance /// - `Err(WasmUtxoError)` if the PSBT is not fully finalized or extraction fails - pub fn extract_transaction(&self) -> Result { + pub fn extract_transaction( + &self, + max_fee_rate_sat_per_vb: Option, + ) -> Result { use crate::fixed_script_wallet::bitgo_psbt::BitGoPsbt as InnerBitGoPsbt; + let policy = fee_policy_from_js(max_fee_rate_sat_per_vb); match &self.psbt { InnerBitGoPsbt::BitcoinLike(..) => { let tx = self .psbt .clone() - .extract_bitcoin_tx() + .extract_bitcoin_tx_with_fee_policy(policy) .map_err(|e| WasmUtxoError::new(&e))?; Ok(crate::wasm::transaction::WasmTransaction::from_tx(tx).into()) } @@ -1872,7 +1902,7 @@ impl BitGoPsbt { let parts = self .psbt .clone() - .extract_dash_tx() + .extract_dash_tx_with_fee_policy(policy) .map_err(|e| WasmUtxoError::new(&e))?; Ok(crate::wasm::dash_transaction::WasmDashTransaction::from_parts(parts).into()) } @@ -1880,7 +1910,7 @@ impl BitGoPsbt { let parts = self .psbt .clone() - .extract_zcash_tx() + .extract_zcash_tx_with_fee_policy(policy) .map_err(|e| WasmUtxoError::new(&e))?; Ok(crate::wasm::transaction::WasmZcashTransaction::from_parts(parts).into()) } @@ -1893,11 +1923,12 @@ impl BitGoPsbt { /// Only valid for Bitcoin-like networks (not Dash or Zcash). pub fn extract_bitcoin_transaction( &self, + max_fee_rate_sat_per_vb: Option, ) -> Result { let tx = self .psbt .clone() - .extract_bitcoin_tx() + .extract_bitcoin_tx_with_fee_policy(fee_policy_from_js(max_fee_rate_sat_per_vb)) .map_err(|e| WasmUtxoError::new(&e))?; Ok(crate::wasm::transaction::WasmTransaction::from_tx(tx)) } @@ -1908,11 +1939,12 @@ impl BitGoPsbt { /// Only valid for Dash networks. pub fn extract_dash_transaction( &self, + max_fee_rate_sat_per_vb: Option, ) -> Result { let parts = self .psbt .clone() - .extract_dash_tx() + .extract_dash_tx_with_fee_policy(fee_policy_from_js(max_fee_rate_sat_per_vb)) .map_err(|e| WasmUtxoError::new(&e))?; Ok(crate::wasm::dash_transaction::WasmDashTransaction::from_parts(parts)) } @@ -1923,11 +1955,12 @@ impl BitGoPsbt { /// Only valid for Zcash networks. pub fn extract_zcash_transaction( &self, + max_fee_rate_sat_per_vb: Option, ) -> Result { let parts = self .psbt .clone() - .extract_zcash_tx() + .extract_zcash_tx_with_fee_policy(fee_policy_from_js(max_fee_rate_sat_per_vb)) .map_err(|e| WasmUtxoError::new(&e))?; Ok(crate::wasm::transaction::WasmZcashTransaction::from_parts( parts,