Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 5 additions & 2 deletions packages/wasm-utxo/js/fixedScriptWallet/BitGoPsbt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -959,12 +959,15 @@ export class BitGoPsbt extends PsbtBase<WasmBitGoPsbt> 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":
Expand Down
7 changes: 5 additions & 2 deletions packages/wasm-utxo/js/fixedScriptWallet/ZcashBitGoPsbt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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));
}
}
174 changes: 161 additions & 13 deletions packages/wasm-utxo/src/fixed_script_wallet/bitgo_psbt/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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<miniscript::bitcoin::Transaction, String> {
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<BitGoPsbt, DeserializeError> {
Expand Down Expand Up @@ -1371,16 +1410,30 @@ impl BitGoPsbt {
/// * `Ok(Vec<u8>)` - The serialized transaction bytes
/// * `Err(String)` - If transaction extraction fails
pub fn extract_tx(self) -> Result<Vec<u8>, 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<u8>)` - 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<Vec<u8>, 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))
}
}
Expand All @@ -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<miniscript::bitcoin::Transaction, String> {
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<miniscript::bitcoin::Transaction, String> {
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()),
}
}
Expand All @@ -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<crate::dash::transaction::DashTransactionParts, String> {
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<crate::dash::transaction::DashTransactionParts, String> {
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))
Expand All @@ -1428,11 +1502,24 @@ impl BitGoPsbt {
/// * `Err(String)` - If not Zcash or extraction fails
pub fn extract_zcash_tx(
self,
) -> Result<crate::zcash::transaction::ZcashTransactionParts, String> {
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<crate::zcash::transaction::ZcashTransactionParts, String> {
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))
Expand Down Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,17 @@ impl ZcashBitGoPsbt {
///
/// This method consumes the PSBT to avoid cloning.
pub fn extract_tx(self) -> Result<Vec<u8>, 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<Vec<u8>, super::DeserializeError> {
use miniscript::bitcoin::psbt::ExtractTxError;

// Capture Zcash-specific fields before consuming psbt
Expand All @@ -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))
}
Expand All @@ -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,
Expand Down
Loading
Loading