From 9f6f7f65260765958e2f727c11dbace77b1e9ac7 Mon Sep 17 00:00:00 2001 From: Otto Allmendinger Date: Mon, 29 Jun 2026 14:50:53 +0200 Subject: [PATCH] feat(wasm-utxo): per-coin maxFeeRate absurd-fee limits Add a per-coin max fee rate policy so PSBT extraction can reject absurd-fee transactions without falsely rejecting low-fee coins. Dogecoin's default fee rate (~50_000_000 sat/kB on a ~1 vB tx) exceeds Bitcoin's DEFAULT_MAX_FEE_RATE (25_000 sat/vB), so the stock absurd-fee guard rejects valid DOGE PSBTs. Fix by deriving the limit per network: - fees::get_max_fee_rate_sat_per_kb(network): None (Unlimited) for the Dogecoin family, Some(1_000_000_000) for BTC/LTC/ZEC + testnets. - FeeRateLimit (Default | Limited | Unlimited) selects extract_tx_with_fee_rate_limit vs. unchecked extract_tx. - JS: fees.getMaxFeeRateSatPerKB(coin) + FeesNamespace; BitGoPsbt and ZcashBitGoPsbt .extractTransaction(maxFeeRate?) forward to the wasm extract_transaction / extract_zcash_transaction params. Refs: T1-3656 --- packages/wasm-utxo/js/fees.ts | 52 ++++ .../js/fixedScriptWallet/BitGoPsbt.ts | 9 +- .../js/fixedScriptWallet/ZcashBitGoPsbt.ts | 6 +- packages/wasm-utxo/js/index.ts | 1 + packages/wasm-utxo/src/fees.rs | 221 +++++++++++++++ .../src/fixed_script_wallet/bitgo_psbt/mod.rs | 258 ++++++++++++++++-- .../bitgo_psbt/zcash_psbt.rs | 62 +++-- packages/wasm-utxo/src/lib.rs | 1 + packages/wasm-utxo/src/wasm/fees.rs | 105 +++++++ .../src/wasm/fixed_script_wallet/mod.rs | 38 ++- packages/wasm-utxo/src/wasm/mod.rs | 2 + 11 files changed, 706 insertions(+), 49 deletions(-) create mode 100644 packages/wasm-utxo/js/fees.ts create mode 100644 packages/wasm-utxo/src/fees.rs create mode 100644 packages/wasm-utxo/src/wasm/fees.rs diff --git a/packages/wasm-utxo/js/fees.ts b/packages/wasm-utxo/js/fees.ts new file mode 100644 index 00000000000..62abf7fc817 --- /dev/null +++ b/packages/wasm-utxo/js/fees.ts @@ -0,0 +1,52 @@ +import { FeesNamespace } from "./wasm/wasm_utxo.js"; +import type { CoinName } from "./coinName.js"; + +/** + * Maximum fee rate (base units per 1000 virtual bytes) for a coin. + * + * Returns `Infinity` when the coin has no fee-rate limit (DOGE/tDOGE). Callers + * should forward `Infinity` to `BitGoPsbt.extractTransaction(maxFeeRate)` to + * skip the absurd-fee check during extraction. + * + * Production per-coin default. Env-specific overrides (e.g. `local_test_suite`) + * are applied by the caller (wallet-platform's `fees.ts` wrapper). + * + * @param coin - Coin name (e.g., "btc", "tbtc", "doge", "tdoge", "ltc") + * @returns max fee rate in base units per 1000 virtual bytes, or `Infinity` + * + * @example + * ```typescript + * import { fixedScriptWallet } from '@bitgo/wasm-utxo'; + * const max = fixedScriptWallet.getMaxFeeRateSatPerKB('tdoge'); // Infinity + * const maxBtc = fixedScriptWallet.getMaxFeeRateSatPerKB('btc'); // 1_000_000_000 + * ``` + */ +export function getMaxFeeRateSatPerKB(coin: CoinName): number { + return FeesNamespace.getMaxFeeRateSatPerKB(coin); +} + +/** + * Minimum fee rate (base units per 1000 virtual bytes) for a coin. + * + * Production per-coin default. Env-specific overrides (e.g. `local_test_suite` + * lowers DOGE from 50_000_000 to 1_000_000) are applied by the caller. + * + * @param coin - Coin name (e.g., "btc", "doge", "ltc") + * @returns min fee rate in base units per 1000 virtual bytes + */ +export function getMinFeeRateSatPerKB(coin: CoinName): number { + return FeesNamespace.getMinFeeRateSatPerKB(coin); +} + +/** + * Default fee rate (base units per 1000 virtual bytes) for a coin. + * + * Used when the caller does not supply an explicit fee rate. Production + * per-coin default. + * + * @param coin - Coin name (e.g., "btc", "doge", "ltc") + * @returns default fee rate in base units per 1000 virtual bytes + */ +export function getDefaultFeeRateSatPerKB(coin: CoinName): number { + return FeesNamespace.getDefaultFeeRateSatPerKB(coin); +} diff --git a/packages/wasm-utxo/js/fixedScriptWallet/BitGoPsbt.ts b/packages/wasm-utxo/js/fixedScriptWallet/BitGoPsbt.ts index 03d025c5858..a1f31b05f7a 100644 --- a/packages/wasm-utxo/js/fixedScriptWallet/BitGoPsbt.ts +++ b/packages/wasm-utxo/js/fixedScriptWallet/BitGoPsbt.ts @@ -959,12 +959,17 @@ export class BitGoPsbt extends PsbtBase implements IPsbtWithAddre /** * Extract the final transaction from a finalized PSBT * + * @param maxFeeRate - Optional max fee rate override in base units per 1000 + * virtual bytes (sat/kvB). Omit/`undefined` to use the per-coin default + * from `fees.getMaxFeeRateSatPerKB`; `Infinity` to skip the absurd-fee + * check entirely; a finite number to reject extraction if the fee rate + * exceeds it. * @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..2b9372dc933 100644 --- a/packages/wasm-utxo/js/fixedScriptWallet/ZcashBitGoPsbt.ts +++ b/packages/wasm-utxo/js/fixedScriptWallet/ZcashBitGoPsbt.ts @@ -283,10 +283,12 @@ export class ZcashBitGoPsbt extends BitGoPsbt { /** * Extract the final Zcash transaction from a finalized PSBT * + * @param maxFeeRate - Optional max fee rate override in base units per 1000 + * virtual bytes (sat/kvB). See {@link BitGoPsbt.extractTransaction}. * @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/js/index.ts b/packages/wasm-utxo/js/index.ts index 331e64b8118..f9338d72841 100644 --- a/packages/wasm-utxo/js/index.ts +++ b/packages/wasm-utxo/js/index.ts @@ -13,6 +13,7 @@ export * as bip322 from "./bip322/index.js"; export * as inscriptions from "./inscriptions.js"; export * as message from "./message.js"; export * as utxolibCompat from "./utxolibCompat.js"; +export * as fees from "./fees.js"; export * as fixedScriptWallet from "./fixedScriptWallet/index.js"; export * as descriptorWallet from "./descriptorWallet/index.js"; export * as bip32 from "./bip32.js"; diff --git a/packages/wasm-utxo/src/fees.rs b/packages/wasm-utxo/src/fees.rs new file mode 100644 index 00000000000..a6fb7f5ea6d --- /dev/null +++ b/packages/wasm-utxo/src/fees.rs @@ -0,0 +1,221 @@ +//! Per-coin fee-rate thresholds — the canonical source of truth for UTXO fee policy. +//! +//! Ported from wallet-platform `config/env/utxoCoins/fees.ts`. The values here are +//! the **production** per-coin defaults and are intentionally env-agnostic: this is a +//! pure compute library and does not know about wallet-platform environments +//! (`production` / `testnet` / `local_test_suite` / ...). Callers that need +//! env-specific overrides (e.g. `local_test_suite` lowers the DOGE min from +//! 50_000_000 to 1_000_000) layer those on top via the parametric `max_fee_rate` +//! override on `BitGoPsbt::extract_tx_with_fee_rate`. +//! +//! Units are **base units per 1000 virtual bytes** (sat/kvB), matching +//! `coin.config.tx.maxFeeRateSatPerKB` in wallet-platform. `1 vB = 4 wu`, so +//! `sat/kvB = sat_per_vB * 1000`. + +use crate::Network; +use miniscript::bitcoin::FeeRate; + +/// Maximum fee rate for a coin, in base units per 1000 virtual bytes. +/// +/// Returns `None` to signal "unlimited" — the caller should skip the absurd-fee +/// check entirely (`Psbt::extract_tx_unchecked_fee_rate`). +/// +/// - DOGE / tDOGE: unlimited. The DOGE base unit is low-value (~$0.076), so a +/// normal DOGE fee in base units far exceeds rust-bitcoin's BTC-calibrated +/// `DEFAULT_MAX_FEE_RATE` (25_000 sat/vB) and would be falsely rejected. +/// - All other UTXO coins: `1_000_000_000` (1e9 sat/kvB = 1e6 sat/vB) — +/// effectively unlimited but finite, matching wallet-platform's historical +/// default. The real fee sanity check is enforced upstream by wallet-platform's +/// circuit breakers and the build path's `maximumFeeRate`. +pub fn get_max_fee_rate_sat_per_kb(network: Network) -> Option { + if network.mainnet() == Network::Dogecoin { + return None; + } + Some(1_000_000_000) +} + +/// Minimum fee rate for a coin, in base units per 1000 virtual bytes. +/// +/// Production values. `local_test_suite` overrides (DOGE → 1_000_000, LTC → 1_001) +/// are applied by the wallet-platform wrapper, not here. +pub fn get_min_fee_rate_sat_per_kb(network: Network) -> u64 { + match network { + Network::Bitcoin + | Network::BitcoinTestnet3 + | Network::BitcoinTestnet4 + | Network::BitcoinPublicSignet + | Network::BitcoinBitGoSignet + | Network::BitcoinRegtest => 101, + + Network::Litecoin => 1_000, + Network::LitecoinTestnet => 1_001, + + Network::BitcoinCash + | Network::BitcoinCashTestnet + | Network::Ecash + | Network::EcashTestnet + | Network::BitcoinGold + | Network::BitcoinGoldTestnet + | Network::BitcoinSV + | Network::BitcoinSVTestnet => 1_000, + + Network::Dash => 10_000, + Network::DashTestnet => 1_001, + + // Production/testnet value. local_test_suite overrides to 1_000_000. + Network::Dogecoin | Network::DogecoinTestnet => 50_000_000, + + Network::Zcash | Network::ZcashTestnet => 150_000, + } +} + +/// Default fee rate for a coin, in base units per 1000 virtual bytes. +/// +/// Used when the caller does not supply an explicit fee rate. Production values. +pub fn get_default_fee_rate_sat_per_kb(network: Network) -> u64 { + match network.mainnet() { + Network::Bitcoin => 10_000, + Network::BitcoinCash | Network::BitcoinSV | Network::Ecash => 2_000, + Network::BitcoinGold => 5_000, + Network::Dash => { + if network.is_mainnet() { + 11_000 + } else { + 1_100 + } + } + Network::Dogecoin => get_min_fee_rate_sat_per_kb(network), + Network::Litecoin => 1_100, + Network::Zcash => 150_000, + // Unreachable: mainnet() covers all variants. + _ => 10_000, + } +} + +/// Fee-rate policy for `BitGoPsbt::extract_tx_with_fee_rate`. +/// +/// Controls whether the absurd-fee check runs during PSBT extraction and at what +/// threshold. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum FeeRateLimit { + /// Use the per-coin default from [`get_max_fee_rate_sat_per_kb`]. + Default, + /// Skip the absurd-fee check entirely (`extract_tx_unchecked_fee_rate`). + Unlimited, + /// Explicit per-call override. `FeeRate` is in rust-bitcoin's internal units + /// (sat/kwu); construct it via [`FeeRateLimit::from_sat_per_kb`] or + /// [`FeeRateLimit::from_sat_per_vb`]. + Limited(FeeRate), +} + +impl FeeRateLimit { + /// Build a `Limited` override from a sat/kvB value (wallet-platform's + /// `maxFeeRateSatPerKB` unit). `sat_per_kb / 1000` yields sat/vB. + pub fn from_sat_per_kb(sat_per_kb: u64) -> Self { + let sat_per_vb = sat_per_kb / 1000; + FeeRateLimit::Limited(FeeRate::from_sat_per_vb_unchecked(sat_per_vb)) + } + + /// Build a `Limited` override from a sat/vB value. + pub fn from_sat_per_vb(sat_per_vb: u64) -> Self { + FeeRateLimit::Limited(FeeRate::from_sat_per_vb_unchecked(sat_per_vb)) + } + + /// Resolve `Default` against a network into a concrete `Unlimited` or + /// `Limited` policy. `Unlimited` and `Limited` pass through unchanged. + pub fn resolve(self, network: Network) -> Self { + match self { + FeeRateLimit::Default => match get_max_fee_rate_sat_per_kb(network) { + None => FeeRateLimit::Unlimited, + Some(sat_per_kb) => Self::from_sat_per_kb(sat_per_kb), + }, + other => other, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn doge_max_is_unlimited() { + assert_eq!(get_max_fee_rate_sat_per_kb(Network::Dogecoin), None); + assert_eq!(get_max_fee_rate_sat_per_kb(Network::DogecoinTestnet), None); + } + + #[test] + fn non_doge_max_is_finite() { + assert_eq!( + get_max_fee_rate_sat_per_kb(Network::Bitcoin), + Some(1_000_000_000) + ); + assert_eq!( + get_max_fee_rate_sat_per_kb(Network::LitecoinTestnet), + Some(1_000_000_000) + ); + assert_eq!( + get_max_fee_rate_sat_per_kb(Network::Zcash), + Some(1_000_000_000) + ); + } + + #[test] + fn min_fee_rates() { + assert_eq!(get_min_fee_rate_sat_per_kb(Network::Bitcoin), 101); + assert_eq!(get_min_fee_rate_sat_per_kb(Network::BitcoinTestnet4), 101); + assert_eq!(get_min_fee_rate_sat_per_kb(Network::Dogecoin), 50_000_000); + assert_eq!( + get_min_fee_rate_sat_per_kb(Network::DogecoinTestnet), + 50_000_000 + ); + assert_eq!(get_min_fee_rate_sat_per_kb(Network::Litecoin), 1_000); + assert_eq!(get_min_fee_rate_sat_per_kb(Network::LitecoinTestnet), 1_001); + assert_eq!(get_min_fee_rate_sat_per_kb(Network::Dash), 10_000); + assert_eq!(get_min_fee_rate_sat_per_kb(Network::DashTestnet), 1_001); + assert_eq!(get_min_fee_rate_sat_per_kb(Network::Zcash), 150_000); + } + + #[test] + fn default_fee_rates() { + assert_eq!(get_default_fee_rate_sat_per_kb(Network::Bitcoin), 10_000); + assert_eq!( + get_default_fee_rate_sat_per_kb(Network::Dogecoin), + 50_000_000 + ); + assert_eq!(get_default_fee_rate_sat_per_kb(Network::Dash), 11_000); + assert_eq!(get_default_fee_rate_sat_per_kb(Network::DashTestnet), 1_100); + assert_eq!(get_default_fee_rate_sat_per_kb(Network::Litecoin), 1_100); + assert_eq!(get_default_fee_rate_sat_per_kb(Network::Zcash), 150_000); + } + + #[test] + fn default_resolves_to_unlimited_for_doge() { + assert_eq!( + FeeRateLimit::Default.resolve(Network::Dogecoin), + FeeRateLimit::Unlimited + ); + assert_eq!( + FeeRateLimit::Default.resolve(Network::DogecoinTestnet), + FeeRateLimit::Unlimited + ); + } + + #[test] + fn default_resolves_to_limited_for_btc() { + match FeeRateLimit::Default.resolve(Network::Bitcoin) { + FeeRateLimit::Limited(_) => {} + other => panic!("expected Limited, got {:?}", other), + } + } + + #[test] + fn unlimited_and_limited_pass_through_resolve() { + assert_eq!( + FeeRateLimit::Unlimited.resolve(Network::Bitcoin), + FeeRateLimit::Unlimited + ); + let limited = FeeRateLimit::from_sat_per_kb(1_000_000_000); + assert_eq!(limited.resolve(Network::Dogecoin), limited); + } +} 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..337e3657888 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 @@ -14,6 +14,7 @@ pub mod psbt_wallet_output; mod sighash; pub mod zcash_psbt; +use crate::fees::FeeRateLimit; use crate::Network; pub use dash_psbt::DashBitGoPsbt; use miniscript::bitcoin::{psbt::Psbt, secp256k1, CompressedPublicKey, Txid}; @@ -339,6 +340,30 @@ pub(crate) fn make_psbt_with_xpubs( psbt } +/// Extract a `Transaction` from a rust-bitcoin `Psbt` applying a `FeeRateLimit` +/// policy. Shared by the `BitcoinLike` and `Dash` branches (both hold an inner +/// `Psbt`). `Default` is resolved against `network` before dispatch. +fn extract_inner_with_fee_rate( + psbt: miniscript::bitcoin::psbt::Psbt, + limit: FeeRateLimit, + network: Network, +) -> Result { + use miniscript::bitcoin::psbt::ExtractTxError; + match limit.resolve(network) { + FeeRateLimit::Unlimited => Ok(psbt.extract_tx_unchecked_fee_rate()), + FeeRateLimit::Limited(max_fee_rate) => psbt + .extract_tx_with_fee_rate_limit(max_fee_rate) + .map_err(|e| match e { + ExtractTxError::AbsurdFeeRate { .. } => { + format!("Failed to extract transaction: {}", e) + } + _ => format!("Failed to extract transaction: {}", e), + }), + // Resolved above; Default is never returned by resolve(). + FeeRateLimit::Default => unreachable!("FeeRateLimit::Default resolved before dispatch"), + } +} + impl BitGoPsbt { /// Deserialize a PSBT from bytes, using network-specific logic pub fn deserialize(psbt_bytes: &[u8], network: Network) -> Result { @@ -1357,10 +1382,11 @@ impl BitGoPsbt { /// Extract the finalized transaction bytes with network-appropriate serialization /// - /// This method extracts the fully-signed transaction from a finalized PSBT, - /// serializing it with the correct format for the network: - /// - For Zcash: includes version_group_id, expiry_height, and sapling fields - /// - For other networks: uses standard Bitcoin transaction serialization + /// Extract the fully-signed transaction from a finalized PSBT using the + /// per-coin default fee-rate limit from the `fees` module. + /// + /// Equivalent to `extract_tx_with_fee_rate(FeeRateLimit::Default)`. Preserved + /// for backward compatibility with existing callers. /// /// This method consumes the PSBT since the underlying `extract_tx()` requires ownership. /// @@ -1371,48 +1397,94 @@ 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_rate(FeeRateLimit::Default) + } + + /// Extract the fully-signed transaction from a finalized PSBT with an + /// explicit fee-rate policy. + /// + /// `FeeRateLimit::Default` resolves against the PSBT's network using + /// `fees::get_max_fee_rate_sat_per_kb` (e.g. DOGE → `Unlimited`, BTC → + /// `Limited(1e9 sat/kvB)`). `Unlimited` skips the absurd-fee check via + /// `Psbt::extract_tx_unchecked_fee_rate`; `Limited(rate)` enforces the + /// given threshold via `Psbt::extract_tx_with_fee_rate_limit`. + /// + /// 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` policy is enforced) + pub fn extract_tx_with_fee_rate(self, limit: FeeRateLimit) -> Result, String> { use miniscript::bitcoin::consensus::serialize; match self { - BitGoPsbt::Zcash(zcash_psbt, _) => zcash_psbt - .extract_tx() + BitGoPsbt::Zcash(zcash_psbt, network) => zcash_psbt + .extract_tx_with_fee_rate(limit, network) .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))?; + BitGoPsbt::BitcoinLike(psbt, network) + | BitGoPsbt::Dash(DashBitGoPsbt { psbt, .. }, network) => { + let tx = extract_inner_with_fee_rate(psbt, limit, network)?; Ok(serialize(&tx)) } } } /// Extract the Bitcoin transaction directly (for BitcoinLike networks only) + /// using the per-coin default fee-rate limit. /// /// # Returns /// * `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_rate(FeeRateLimit::Default) + } + + /// Extract the Bitcoin transaction directly (for BitcoinLike networks only) + /// with an explicit fee-rate policy. See [`extract_tx_with_fee_rate`]. + /// + /// # Returns + /// * `Ok(Transaction)` - The extracted transaction + /// * `Err(String)` - If not BitcoinLike or extraction fails + pub fn extract_bitcoin_tx_with_fee_rate( + self, + limit: FeeRateLimit, + ) -> Result { match self { - BitGoPsbt::BitcoinLike(psbt, _) => psbt - .extract_tx() - .map_err(|e| format!("Failed to extract transaction: {}", e)), + BitGoPsbt::BitcoinLike(psbt, network) => { + extract_inner_with_fee_rate(psbt, limit, network) + } _ => Err("extract_bitcoin_tx only supported for BitcoinLike networks".to_string()), } } - /// Extract the Dash transaction parts directly + /// Extract the Dash transaction parts directly using the per-coin default + /// fee-rate limit. /// /// # Returns /// * `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_rate(FeeRateLimit::Default) + } + + /// Extract the Dash transaction parts directly with an explicit fee-rate + /// policy. See [`extract_tx_with_fee_rate`]. + /// + /// # Returns + /// * `Ok(DashTransactionParts)` - The extracted transaction parts + /// * `Err(String)` - If not Dash or extraction fails + pub fn extract_dash_tx_with_fee_rate( + self, + limit: FeeRateLimit, + ) -> 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))?; + BitGoPsbt::Dash(dash_psbt, network) => { + let tx = extract_inner_with_fee_rate(dash_psbt.psbt, limit, network)?; let tx_bytes = serialize(&tx); crate::dash::transaction::decode_dash_transaction_parts(&tx_bytes) .map_err(|e| format!("Failed to decode Dash transaction: {}", e)) @@ -1421,18 +1493,32 @@ impl BitGoPsbt { } } - /// Extract the Zcash transaction parts directly + /// Extract the Zcash transaction parts directly using the per-coin default + /// fee-rate limit. /// /// # Returns /// * `Ok(ZcashTransactionParts)` - The extracted transaction parts /// * `Err(String)` - If not Zcash or extraction fails pub fn extract_zcash_tx( self, + ) -> Result { + self.extract_zcash_tx_with_fee_rate(FeeRateLimit::Default) + } + + /// Extract the Zcash transaction parts directly with an explicit fee-rate + /// policy. See [`extract_tx_with_fee_rate`]. + /// + /// # Returns + /// * `Ok(ZcashTransactionParts)` - The extracted transaction parts + /// * `Err(String)` - If not Zcash or extraction fails + pub fn extract_zcash_tx_with_fee_rate( + self, + limit: FeeRateLimit, ) -> Result { match self { - BitGoPsbt::Zcash(zcash_psbt, _) => { + BitGoPsbt::Zcash(zcash_psbt, network) => { let bytes = zcash_psbt - .extract_tx() + .extract_tx_with_fee_rate(limit, network) .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)) @@ -5377,6 +5463,134 @@ mod tests { assert_eq!(decoded.compute_txid(), extracted_tx.compute_txid()); } + /// Build a finalized single-input/single-output PSBT for `network` where the + /// fee (`input_value - output_value`) is deliberately absurd by Bitcoin + /// standards. Used to exercise the per-coin fee-rate policy on extraction. + fn build_finalized_absurd_fee_psbt( + network: Network, + input_value: u64, + output_value: u64, + ) -> BitGoPsbt { + use crate::fixed_script_wallet::test_utils::get_test_wallet_keys; + use miniscript::bitcoin::bip32::{DerivationPath, Xpriv}; + use miniscript::bitcoin::hashes::{sha256, Hash}; + use miniscript::bitcoin::secp256k1::Secp256k1; + use miniscript::bitcoin::{Network as BitcoinNetwork, Txid}; + use std::str::FromStr; + + let wallet_keys = RootWalletKeys::new(get_test_wallet_keys("absurd_fee_seed")); + let mut psbt = BitGoPsbt::new(network, &wallet_keys, Some(2), Some(0)); + + let txid = Txid::all_zeros(); + let vout = 0u32; + let script_id = ScriptId { chain: 0, index: 0 }; + psbt.add_wallet_input( + txid, + vout, + input_value, + &wallet_keys, + script_id, + crate::fixed_script_wallet::bitgo_psbt::psbt_wallet_input::WalletInputOptions::default( + ), + ) + .expect("add_wallet_input"); + psbt.add_wallet_output(0, 0, output_value, &wallet_keys) + .expect("add_wallet_output"); + + let secp = Secp256k1::new(); + let seed = "absurd_fee_seed"; + let user_seed_hash = sha256::Hash::hash(format!("{}.0", seed).as_bytes()).to_byte_array(); + let bitgo_seed_hash = sha256::Hash::hash(format!("{}.2", seed).as_bytes()).to_byte_array(); + let user_xpriv = + Xpriv::new_master(BitcoinNetwork::Testnet, &user_seed_hash).expect("user xpriv"); + let bitgo_xpriv = + Xpriv::new_master(BitcoinNetwork::Testnet, &bitgo_seed_hash).expect("bitgo xpriv"); + let user_path = DerivationPath::from_str("m/0/0/0/0").expect("derivation path"); + let user_privkey = user_xpriv + .derive_priv(&secp, &user_path) + .expect("derive user xpriv") + .private_key; + let bitgo_privkey = bitgo_xpriv + .derive_priv(&secp, &user_path) + .expect("derive bitgo xpriv") + .private_key; + + psbt.sign_with_privkey(0, &user_privkey) + .expect("sign_with_privkey"); + psbt.sign_with_privkey(0, &bitgo_privkey) + .expect("sign_with_privkey (bitgo)"); + + psbt.finalize_mut(&secp).expect("finalize"); + psbt + } + + /// Regression for the tDOGE "absurdly high fee rate" extract failures: DOGE + /// fees in base units routinely exceed rust-bitcoin's BTC-calibrated + /// `DEFAULT_MAX_FEE_RATE` (25_000 sat/vB), so the per-coin default for DOGE + /// must be `Unlimited`. A finalized DOGE PSBT with an absurd fee must + /// extract successfully via the default `extract_tx()`. + #[test] + fn test_extract_tx_dogecoin_absurd_fee_default_succeeds() { + // input 1e19 sat, output 1 sat → fee ≈ 1e19 sat over a ~200 vB tx, + // far above BTC's 25_000 sat/vB threshold. + let psbt = build_finalized_absurd_fee_psbt(Network::Dogecoin, 1_000_000_000_000_000_000, 1); + let bytes = psbt + .extract_tx() + .expect("DOGE absurd-fee PSBT must extract under per-coin default (unlimited)"); + assert!(!bytes.is_empty()); + } + + /// BTC must still reject an absurd fee under its per-coin default + /// (`1_000_000_000 sat/kvB = 1_000_000 sat/vB`). A fee of ~1e19 sat is + /// many orders of magnitude above that threshold. + #[test] + fn test_extract_tx_bitcoin_absurd_fee_default_rejects() { + let psbt = build_finalized_absurd_fee_psbt(Network::Bitcoin, 1_000_000_000_000_000_000, 1); + let err = psbt + .extract_tx() + .expect_err("BTC absurd-fee PSBT must be rejected under per-coin default"); + assert!( + err.to_lowercase().contains("fee"), + "expected fee-related error, got: {err}" + ); + } + + /// An explicit `Unlimited` override must skip the absurd-fee check even for + /// BTC (mirrors DOGE's per-coin default and the JS `Infinity` override). + #[test] + fn test_extract_tx_bitcoin_absurd_fee_unlimited_succeeds() { + let psbt = build_finalized_absurd_fee_psbt(Network::Bitcoin, 1_000_000_000_000_000_000, 1); + let bytes = psbt + .extract_tx_with_fee_rate(crate::fees::FeeRateLimit::Unlimited) + .expect("Unlimited override must skip the absurd-fee check"); + assert!(!bytes.is_empty()); + } + + /// An explicit low `Limited` override must reject an absurd-fee BTC PSBT. + #[test] + fn test_extract_tx_bitcoin_absurd_fee_low_limit_rejects() { + let psbt = build_finalized_absurd_fee_psbt(Network::Bitcoin, 1_000_000_000_000_000_000, 1); + let err = psbt + .extract_tx_with_fee_rate(crate::fees::FeeRateLimit::from_sat_per_vb(1)) + .expect_err("low limit must reject absurd-fee PSBT"); + assert!( + err.to_lowercase().contains("fee"), + "expected fee-related error, got: {err}" + ); + } + + /// A normal-fee BTC PSBT must extract under the per-coin default. + #[test] + fn test_extract_tx_bitcoin_normal_fee_default_succeeds() { + // 1 BTC in, 0.999 BTC out → 0.001 BTC fee = 100_000 sat over ~200 vB + // ≈ 500 sat/vB, well under the 1_000_000 sat/vB default. + let psbt = build_finalized_absurd_fee_psbt(Network::Bitcoin, 100_000_000, 99_900_000); + let bytes = psbt + .extract_tx() + .expect("normal-fee BTC PSBT must extract under default"); + assert!(!bytes.is_empty()); + } + #[test] fn test_get_global_xpubs() { use crate::fixed_script_wallet::test_utils::get_test_wallet_keys; 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..48284a3b86c 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 @@ -154,13 +154,29 @@ impl ZcashBitGoPsbt { self.serialize_as_zcash_transaction(&self.psbt.unsigned_tx) } - /// Extract the finalized Zcash transaction bytes from the PSBT + /// Extract the finalized Zcash transaction bytes from the PSBT using the + /// per-coin default fee-rate limit. /// - /// This extracts the fully-signed transaction with Zcash-specific fields. - /// Must be called after all inputs have been finalized. - /// - /// This method consumes the PSBT to avoid cloning. + /// Equivalent to `extract_tx_with_fee_rate(FeeRateLimit::Default, network)`. pub fn extract_tx(self) -> Result, super::DeserializeError> { + let network = self.network; + self.extract_tx_with_fee_rate(crate::fees::FeeRateLimit::Default, network) + } + + /// Extract the finalized Zcash transaction bytes from the PSBT with an + /// explicit fee-rate policy. + /// + /// `FeeRateLimit::Default` resolves against `network` via + /// `fees::get_max_fee_rate_sat_per_kb`. `Unlimited` skips the absurd-fee + /// check (`extract_tx_unchecked_fee_rate`); `Limited(rate)` enforces it + /// (`extract_tx_with_fee_rate_limit`). + /// + /// Must be called after all inputs have been finalized. Consumes the PSBT. + pub fn extract_tx_with_fee_rate( + self, + limit: crate::fees::FeeRateLimit, + network: crate::Network, + ) -> Result, super::DeserializeError> { use miniscript::bitcoin::psbt::ExtractTxError; // Capture Zcash-specific fields before consuming psbt @@ -170,18 +186,32 @@ 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 { - ExtractTxError::AbsurdFeeRate { .. } => { - super::DeserializeError::Network(format!("Absurd fee rate: {}", e)) - } - ExtractTxError::MissingInputValue { .. } => { - super::DeserializeError::Network(format!("Missing input value: {}", e)) + let resolved = limit.resolve(network); + let tx = match resolved { + crate::fees::FeeRateLimit::Unlimited => self.psbt.extract_tx_unchecked_fee_rate(), + crate::fees::FeeRateLimit::Limited(max_fee_rate) => self + .psbt + .extract_tx_with_fee_rate_limit(max_fee_rate) + .map_err(|e| match e { + ExtractTxError::AbsurdFeeRate { .. } => { + super::DeserializeError::Network(format!("Absurd fee rate: {}", e)) + } + ExtractTxError::MissingInputValue { .. } => { + super::DeserializeError::Network(format!("Missing input value: {}", e)) + } + ExtractTxError::SendingTooMuch { .. } => { + super::DeserializeError::Network(format!("Sending too much: {}", e)) + } + _ => super::DeserializeError::Network(format!( + "Failed to extract transaction: {}", + e + )), + })?, + // Resolved above. + crate::fees::FeeRateLimit::Default => { + unreachable!("FeeRateLimit::Default resolved before dispatch") } - ExtractTxError::SendingTooMuch { .. } => { - super::DeserializeError::Network(format!("Sending too much: {}", e)) - } - _ => super::DeserializeError::Network(format!("Failed to extract transaction: {}", e)), - })?; + }; let parts = crate::zcash::transaction::ZcashTransactionParts { transaction: tx, diff --git a/packages/wasm-utxo/src/lib.rs b/packages/wasm-utxo/src/lib.rs index 4628ec3422f..9f1498bb21c 100644 --- a/packages/wasm-utxo/src/lib.rs +++ b/packages/wasm-utxo/src/lib.rs @@ -2,6 +2,7 @@ mod address; pub mod bip322; pub mod dash; mod error; +pub mod fees; pub mod fixed_script_wallet; pub mod inscriptions; #[cfg(feature = "inspect")] diff --git a/packages/wasm-utxo/src/wasm/fees.rs b/packages/wasm-utxo/src/wasm/fees.rs new file mode 100644 index 00000000000..a6ff7d3886d --- /dev/null +++ b/packages/wasm-utxo/src/wasm/fees.rs @@ -0,0 +1,105 @@ +//! JS-exposed fee-rate thresholds and fee-policy helpers. +//! +//! Mirrors `crate::fees` (the canonical per-coin thresholds) for JS callers. +//! Values are in **base units per 1000 virtual bytes** (sat/kvB), matching +//! wallet-platform's `coin.config.tx.maxFeeRateSatPerKB` unit. +//! +//! `maxFeeRate` overrides passed to `extractTransaction(maxFeeRate)` use the +//! same unit and the JS `Infinity` value signals "unlimited" (skip the +//! absurd-fee check). + +use wasm_bindgen::prelude::*; + +use crate::error::WasmUtxoError; +use crate::fees::{self, FeeRateLimit}; +use crate::networks::Network; + +/// Parse a network from a utxolib name or coin name (mirrors the private +/// `parse_network` in `wasm::fixed_script_wallet`). +fn parse_network(network_str: &str) -> Result { + Network::from_utxolib_name(network_str) + .or_else(|| 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')", + network_str + )) + }) +} + +/// Convert a JS `maxFeeRate` override (sat/kvB, `Infinity` allowed) into a +/// `FeeRateLimit`. `None`/`null`/`undefined` → `Default` (per-coin lookup), +/// `Infinity` → `Unlimited`, any finite number → `Limited`. +pub(crate) fn fee_rate_limit_from_js(max_fee_rate: Option, network: Network) -> FeeRateLimit { + match max_fee_rate { + None => FeeRateLimit::Default, + Some(x) if x.is_infinite() => FeeRateLimit::Unlimited, + Some(x) if x <= 0.0 => { + // A non-positive limit is nonsensical for extraction; treat as + // unlimited to avoid spuriously rejecting every non-zero-fee tx. + FeeRateLimit::Unlimited + } + Some(x) => { + let sat_per_vb = (x / 1000.0).round() as u64; + if sat_per_vb == 0 { + FeeRateLimit::Unlimited + } else { + FeeRateLimit::from_sat_per_vb(sat_per_vb) + } + } + } + .resolve_default_against(network) +} + +impl FeeRateLimit { + /// `Default` cannot be returned to JS callers meaningfully, so resolve it + /// against the network here. Used by `fee_rate_limit_from_js`. + fn resolve_default_against(self, network: Network) -> FeeRateLimit { + match self { + FeeRateLimit::Default => match fees::get_max_fee_rate_sat_per_kb(network) { + None => FeeRateLimit::Unlimited, + Some(sat_per_kb) => FeeRateLimit::from_sat_per_kb(sat_per_kb), + }, + other => other, + } + } +} + +/// JS-exposed per-coin fee-rate thresholds. +/// +/// All getters take a network/coin name (utxolib name like `"bitcoin"` or coin +/// name like `"btc"` / `"tdoge"`) and return the production per-coin default. +/// Env-specific overrides (e.g. `local_test_suite`) are applied by the caller. +#[wasm_bindgen] +pub struct FeesNamespace; + +#[wasm_bindgen] +impl FeesNamespace { + /// Maximum fee rate (base units per 1000 virtual bytes) for the coin. + /// + /// Returns `Infinity` when the coin has no limit (DOGE/tDOGE); callers + /// should pass `Infinity` to `extractTransaction(maxFeeRate)` to skip the + /// absurd-fee check. + #[wasm_bindgen(js_name = getMaxFeeRateSatPerKB)] + pub fn get_max_fee_rate_sat_per_kb(coin: &str) -> Result { + let network = parse_network(coin)?; + Ok(match fees::get_max_fee_rate_sat_per_kb(network) { + None => f64::INFINITY, + Some(v) => v as f64, + }) + } + + /// Minimum fee rate (base units per 1000 virtual bytes) for the coin. + #[wasm_bindgen(js_name = getMinFeeRateSatPerKB)] + pub fn get_min_fee_rate_sat_per_kb(coin: &str) -> Result { + let network = parse_network(coin)?; + Ok(fees::get_min_fee_rate_sat_per_kb(network) as f64) + } + + /// Default fee rate (base units per 1000 virtual bytes) for the coin. + #[wasm_bindgen(js_name = getDefaultFeeRateSatPerKB)] + pub fn get_default_fee_rate_sat_per_kb(coin: &str) -> Result { + let network = parse_network(coin)?; + Ok(fees::get_default_fee_rate_sat_per_kb(network) as f64) + } +} 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..a3aef0e20c8 100644 --- a/packages/wasm-utxo/src/wasm/fixed_script_wallet/mod.rs +++ b/packages/wasm-utxo/src/wasm/fixed_script_wallet/mod.rs @@ -1854,17 +1854,26 @@ impl BitGoPsbt { /// It extracts the fully signed transaction as a WASM transaction instance /// appropriate for the network (WasmTransaction, WasmDashTransaction, or WasmZcashTransaction). /// + /// # Fee-rate policy + /// - `max_fee_rate` omitted/`null` → use the per-coin default from the `fees` + /// module (e.g. DOGE → unlimited, BTC → 1e9 sat/kvB). + /// - `max_fee_rate = Infinity` → skip the absurd-fee check entirely. + /// - `max_fee_rate = ` → reject if the extracted fee rate exceeds + /// `` sat/kvB. + /// /// # 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: Option) -> Result { use crate::fixed_script_wallet::bitgo_psbt::BitGoPsbt as InnerBitGoPsbt; + let network = self.psbt.network(); + let limit = crate::wasm::fees::fee_rate_limit_from_js(max_fee_rate, network); match &self.psbt { InnerBitGoPsbt::BitcoinLike(..) => { let tx = self .psbt .clone() - .extract_bitcoin_tx() + .extract_bitcoin_tx_with_fee_rate(limit) .map_err(|e| WasmUtxoError::new(&e))?; Ok(crate::wasm::transaction::WasmTransaction::from_tx(tx).into()) } @@ -1872,7 +1881,7 @@ impl BitGoPsbt { let parts = self .psbt .clone() - .extract_dash_tx() + .extract_dash_tx_with_fee_rate(limit) .map_err(|e| WasmUtxoError::new(&e))?; Ok(crate::wasm::dash_transaction::WasmDashTransaction::from_parts(parts).into()) } @@ -1880,7 +1889,7 @@ impl BitGoPsbt { let parts = self .psbt .clone() - .extract_zcash_tx() + .extract_zcash_tx_with_fee_rate(limit) .map_err(|e| WasmUtxoError::new(&e))?; Ok(crate::wasm::transaction::WasmZcashTransaction::from_parts(parts).into()) } @@ -1891,13 +1900,18 @@ impl BitGoPsbt { /// /// This avoids re-parsing bytes by returning the transaction directly. /// Only valid for Bitcoin-like networks (not Dash or Zcash). + /// + /// See [`extract_transaction`] for the `max_fee_rate` policy. pub fn extract_bitcoin_transaction( &self, + max_fee_rate: Option, ) -> Result { + let network = self.psbt.network(); + let limit = crate::wasm::fees::fee_rate_limit_from_js(max_fee_rate, network); let tx = self .psbt .clone() - .extract_bitcoin_tx() + .extract_bitcoin_tx_with_fee_rate(limit) .map_err(|e| WasmUtxoError::new(&e))?; Ok(crate::wasm::transaction::WasmTransaction::from_tx(tx)) } @@ -1906,13 +1920,18 @@ impl BitGoPsbt { /// /// This avoids re-parsing bytes by returning the transaction directly. /// Only valid for Dash networks. + /// + /// See [`extract_transaction`] for the `max_fee_rate` policy. pub fn extract_dash_transaction( &self, + max_fee_rate: Option, ) -> Result { + let network = self.psbt.network(); + let limit = crate::wasm::fees::fee_rate_limit_from_js(max_fee_rate, network); let parts = self .psbt .clone() - .extract_dash_tx() + .extract_dash_tx_with_fee_rate(limit) .map_err(|e| WasmUtxoError::new(&e))?; Ok(crate::wasm::dash_transaction::WasmDashTransaction::from_parts(parts)) } @@ -1921,13 +1940,18 @@ impl BitGoPsbt { /// /// This avoids re-parsing bytes by returning the transaction directly. /// Only valid for Zcash networks. + /// + /// See [`extract_transaction`] for the `max_fee_rate` policy. pub fn extract_zcash_transaction( &self, + max_fee_rate: Option, ) -> Result { + let network = self.psbt.network(); + let limit = crate::wasm::fees::fee_rate_limit_from_js(max_fee_rate, network); let parts = self .psbt .clone() - .extract_zcash_tx() + .extract_zcash_tx_with_fee_rate(limit) .map_err(|e| WasmUtxoError::new(&e))?; Ok(crate::wasm::transaction::WasmZcashTransaction::from_parts( parts, diff --git a/packages/wasm-utxo/src/wasm/mod.rs b/packages/wasm-utxo/src/wasm/mod.rs index c47f472cb4c..a49861df1fd 100644 --- a/packages/wasm-utxo/src/wasm/mod.rs +++ b/packages/wasm-utxo/src/wasm/mod.rs @@ -4,6 +4,7 @@ mod bip322; mod dash_transaction; mod descriptor; mod ecpair; +mod fees; mod psbt_ops; #[macro_use] mod psbt; @@ -27,6 +28,7 @@ pub use bip322::Bip322Namespace; pub use dash_transaction::WasmDashTransaction; pub use descriptor::WrapDescriptor; pub use ecpair::WasmECPair; +pub use fees::FeesNamespace; pub use fixed_script_wallet::{BitGoPsbt, FixedScriptWalletNamespace, WasmDimensions}; pub use inscriptions::InscriptionsNamespace; pub use message::MessageNamespace;