Skip to content

Commit 7a2b4d1

Browse files
authored
Merge pull request #308 from BitGo/otto/T1-3656-extract-maxfeerate-param
feat(wasm-utxo): add max_fee_rate param to PSBT extract_transaction
2 parents c1cc3b1 + 3257686 commit 7a2b4d1

5 files changed

Lines changed: 235 additions & 28 deletions

File tree

packages/wasm-utxo/js/fixedScriptWallet/BitGoPsbt.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -959,12 +959,15 @@ export class BitGoPsbt extends PsbtBase<WasmBitGoPsbt> implements IPsbtWithAddre
959959
/**
960960
* Extract the final transaction from a finalized PSBT
961961
*
962+
* @param maxFeeRate Optional maximum fee rate in **sat/vB**. `Infinity` skips
963+
* the absurd-fee check; `undefined` uses rust-bitcoin's default check.
964+
* Callers holding sat/kB thresholds must divide by 1000 before passing.
962965
* @returns The extracted transaction instance
963966
* @throws Error if the PSBT is not fully finalized or extraction fails
964967
*/
965-
extractTransaction(): ITransaction {
968+
extractTransaction(maxFeeRate?: number): ITransaction {
966969
const networkType = this._wasm.get_network_type();
967-
const wasm: unknown = this._wasm.extract_transaction();
970+
const wasm: unknown = this._wasm.extract_transaction(maxFeeRate);
968971

969972
switch (networkType) {
970973
case "dash":

packages/wasm-utxo/js/fixedScriptWallet/ZcashBitGoPsbt.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -283,10 +283,13 @@ export class ZcashBitGoPsbt extends BitGoPsbt {
283283
/**
284284
* Extract the final Zcash transaction from a finalized PSBT
285285
*
286+
* @param maxFeeRate Optional maximum fee rate in **sat/vB**. `Infinity` skips
287+
* the absurd-fee check; `undefined` uses rust-bitcoin's default check.
288+
* Callers holding sat/kB thresholds must divide by 1000 before passing.
286289
* @returns The extracted Zcash transaction instance
287290
* @throws Error if the PSBT is not fully finalized or extraction fails
288291
*/
289-
override extractTransaction(): ZcashTransaction {
290-
return ZcashTransaction.fromWasm(this.wasm.extract_zcash_transaction());
292+
override extractTransaction(maxFeeRate?: number): ZcashTransaction {
293+
return ZcashTransaction.fromWasm(this.wasm.extract_zcash_transaction(maxFeeRate));
291294
}
292295
}

packages/wasm-utxo/src/fixed_script_wallet/bitgo_psbt/mod.rs

Lines changed: 161 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ pub mod zcash_psbt;
1616

1717
use crate::Network;
1818
pub use dash_psbt::DashBitGoPsbt;
19-
use miniscript::bitcoin::{psbt::Psbt, secp256k1, CompressedPublicKey, Txid};
19+
use miniscript::bitcoin::{psbt::Psbt, secp256k1, CompressedPublicKey, FeeRate, Txid};
2020
pub use propkv::{
2121
find_kv, get_zec_consensus_branch_id, BitGoKeyValue, ProprietaryKeySubtype,
2222
WasmUtxoVersionInfo, BITGO,
@@ -339,6 +339,45 @@ pub(crate) fn make_psbt_with_xpubs(
339339
psbt
340340
}
341341

342+
/// Fee-rate policy applied during PSBT extraction.
343+
///
344+
/// Controls how rust-bitcoin's absurd-fee-rate guard behaves when extracting a
345+
/// finalized transaction. This is pure plumbing — it carries no per-coin
346+
/// policy; callers that want a coin-aware default must resolve it themselves
347+
/// and pass [`ExtractFeePolicy::Limited`] or [`ExtractFeePolicy::Unchecked`].
348+
///
349+
/// All fee rates here are in **sat/vB** (rust-bitcoin's `FeeRate` unit). The
350+
/// wasm/JS boundary also uses **sat/vB**; callers holding sat/kB thresholds
351+
/// must convert (÷ 1000) before calling. See `fee_policy_from_js`.
352+
#[derive(Debug, Clone, Copy)]
353+
pub enum ExtractFeePolicy {
354+
/// Use rust-bitcoin's stock absurd-fee-rate check (`Psbt::extract_tx`).
355+
Default,
356+
/// Skip the absurd-fee-rate check entirely (`Psbt::extract_tx_unchecked_fee_rate`).
357+
Unchecked,
358+
/// Enforce a maximum fee rate in **sat/vB**
359+
/// (`Psbt::extract_tx_with_fee_rate_limit`).
360+
Limited(FeeRate),
361+
}
362+
363+
/// Extract a `Transaction` from a rust-bitcoin `Psbt` applying an
364+
/// [`ExtractFeePolicy`]. Shared by the `BitcoinLike` and `Dash` branches,
365+
/// which both hold an inner `Psbt`.
366+
fn extract_inner_with_fee_policy(
367+
psbt: Psbt,
368+
policy: ExtractFeePolicy,
369+
) -> Result<miniscript::bitcoin::Transaction, String> {
370+
match policy {
371+
ExtractFeePolicy::Default => psbt
372+
.extract_tx()
373+
.map_err(|e| format!("Failed to extract transaction: {}", e)),
374+
ExtractFeePolicy::Unchecked => Ok(psbt.extract_tx_unchecked_fee_rate()),
375+
ExtractFeePolicy::Limited(max_fee_rate_sat_per_vb) => psbt
376+
.extract_tx_with_fee_rate_limit(max_fee_rate_sat_per_vb)
377+
.map_err(|e| format!("Failed to extract transaction: {}", e)),
378+
}
379+
}
380+
342381
impl BitGoPsbt {
343382
/// Deserialize a PSBT from bytes, using network-specific logic
344383
pub fn deserialize(psbt_bytes: &[u8], network: Network) -> Result<BitGoPsbt, DeserializeError> {
@@ -1371,16 +1410,30 @@ impl BitGoPsbt {
13711410
/// * `Ok(Vec<u8>)` - The serialized transaction bytes
13721411
/// * `Err(String)` - If transaction extraction fails
13731412
pub fn extract_tx(self) -> Result<Vec<u8>, String> {
1413+
self.extract_tx_with_fee_policy(ExtractFeePolicy::Default)
1414+
}
1415+
1416+
/// Extract the fully-signed transaction from a finalized PSBT with an
1417+
/// explicit fee-rate [`policy`][ExtractFeePolicy].
1418+
///
1419+
/// This method consumes the PSBT since the underlying `extract_tx()` requires ownership.
1420+
///
1421+
/// # Requirements
1422+
/// All inputs must be finalized before calling this method.
1423+
///
1424+
/// # Returns
1425+
/// * `Ok(Vec<u8>)` - The serialized transaction bytes
1426+
/// * `Err(String)` - If transaction extraction fails (including absurd-fee
1427+
/// rejection when a [`Limited`][ExtractFeePolicy::Limited] policy is enforced)
1428+
pub fn extract_tx_with_fee_policy(self, policy: ExtractFeePolicy) -> Result<Vec<u8>, String> {
13741429
use miniscript::bitcoin::consensus::serialize;
13751430

13761431
match self {
13771432
BitGoPsbt::Zcash(zcash_psbt, _) => zcash_psbt
1378-
.extract_tx()
1433+
.extract_tx_with_fee_policy(policy)
13791434
.map_err(|e| format!("Failed to extract transaction: {}", e)),
13801435
BitGoPsbt::BitcoinLike(psbt, _) | BitGoPsbt::Dash(DashBitGoPsbt { psbt, .. }, _) => {
1381-
let tx = psbt
1382-
.extract_tx()
1383-
.map_err(|e| format!("Failed to extract transaction: {}", e))?;
1436+
let tx = extract_inner_with_fee_policy(psbt, policy)?;
13841437
Ok(serialize(&tx))
13851438
}
13861439
}
@@ -1392,10 +1445,21 @@ impl BitGoPsbt {
13921445
/// * `Ok(Transaction)` - The extracted transaction
13931446
/// * `Err(String)` - If not BitcoinLike or extraction fails
13941447
pub fn extract_bitcoin_tx(self) -> Result<miniscript::bitcoin::Transaction, String> {
1448+
self.extract_bitcoin_tx_with_fee_policy(ExtractFeePolicy::Default)
1449+
}
1450+
1451+
/// Extract the Bitcoin transaction directly (for BitcoinLike networks only)
1452+
/// with an explicit fee-rate [`policy`][ExtractFeePolicy].
1453+
///
1454+
/// # Returns
1455+
/// * `Ok(Transaction)` - The extracted transaction
1456+
/// * `Err(String)` - If not BitcoinLike or extraction fails
1457+
pub fn extract_bitcoin_tx_with_fee_policy(
1458+
self,
1459+
policy: ExtractFeePolicy,
1460+
) -> Result<miniscript::bitcoin::Transaction, String> {
13951461
match self {
1396-
BitGoPsbt::BitcoinLike(psbt, _) => psbt
1397-
.extract_tx()
1398-
.map_err(|e| format!("Failed to extract transaction: {}", e)),
1462+
BitGoPsbt::BitcoinLike(psbt, _) => extract_inner_with_fee_policy(psbt, policy),
13991463
_ => Err("extract_bitcoin_tx only supported for BitcoinLike networks".to_string()),
14001464
}
14011465
}
@@ -1406,13 +1470,23 @@ impl BitGoPsbt {
14061470
/// * `Ok(DashTransactionParts)` - The extracted transaction parts
14071471
/// * `Err(String)` - If not Dash or extraction fails
14081472
pub fn extract_dash_tx(self) -> Result<crate::dash::transaction::DashTransactionParts, String> {
1473+
self.extract_dash_tx_with_fee_policy(ExtractFeePolicy::Default)
1474+
}
1475+
1476+
/// Extract the Dash transaction parts directly with an explicit fee-rate
1477+
/// [`policy`][ExtractFeePolicy].
1478+
///
1479+
/// # Returns
1480+
/// * `Ok(DashTransactionParts)` - The extracted transaction parts
1481+
/// * `Err(String)` - If not Dash or extraction fails
1482+
pub fn extract_dash_tx_with_fee_policy(
1483+
self,
1484+
policy: ExtractFeePolicy,
1485+
) -> Result<crate::dash::transaction::DashTransactionParts, String> {
14091486
use miniscript::bitcoin::consensus::serialize;
14101487
match self {
14111488
BitGoPsbt::Dash(dash_psbt, _) => {
1412-
let tx = dash_psbt
1413-
.psbt
1414-
.extract_tx()
1415-
.map_err(|e| format!("Failed to extract transaction: {}", e))?;
1489+
let tx = extract_inner_with_fee_policy(dash_psbt.psbt, policy)?;
14161490
let tx_bytes = serialize(&tx);
14171491
crate::dash::transaction::decode_dash_transaction_parts(&tx_bytes)
14181492
.map_err(|e| format!("Failed to decode Dash transaction: {}", e))
@@ -1428,11 +1502,24 @@ impl BitGoPsbt {
14281502
/// * `Err(String)` - If not Zcash or extraction fails
14291503
pub fn extract_zcash_tx(
14301504
self,
1505+
) -> Result<crate::zcash::transaction::ZcashTransactionParts, String> {
1506+
self.extract_zcash_tx_with_fee_policy(ExtractFeePolicy::Default)
1507+
}
1508+
1509+
/// Extract the Zcash transaction parts directly with an explicit fee-rate
1510+
/// [`policy`][ExtractFeePolicy].
1511+
///
1512+
/// # Returns
1513+
/// * `Ok(ZcashTransactionParts)` - The extracted transaction parts
1514+
/// * `Err(String)` - If not Zcash or extraction fails
1515+
pub fn extract_zcash_tx_with_fee_policy(
1516+
self,
1517+
policy: ExtractFeePolicy,
14311518
) -> Result<crate::zcash::transaction::ZcashTransactionParts, String> {
14321519
match self {
14331520
BitGoPsbt::Zcash(zcash_psbt, _) => {
14341521
let bytes = zcash_psbt
1435-
.extract_tx()
1522+
.extract_tx_with_fee_policy(policy)
14361523
.map_err(|e| format!("Failed to extract transaction: {}", e))?;
14371524
crate::zcash::transaction::decode_zcash_transaction_parts(&bytes)
14381525
.map_err(|e| format!("Failed to decode Zcash transaction: {}", e))
@@ -4119,6 +4206,67 @@ mod tests {
41194206
);
41204207
});
41214208

4209+
/// `extract_tx_with_fee_policy` must produce byte-identical output to the
4210+
/// default `extract_tx()` for a normal-fee PSBT across all three policies
4211+
/// (`Default`, `Unchecked`, `Limited`). This pins the param plumbing added
4212+
/// in this change without depending on per-coin fee policy.
4213+
#[test]
4214+
fn test_extract_tx_with_fee_policy_matches_default() {
4215+
use crate::fixed_script_wallet::test_utils::fixtures::{
4216+
self, FixtureNamespace, SignatureState, TxFormat,
4217+
};
4218+
use crate::Network;
4219+
4220+
let network = Network::Bitcoin;
4221+
let fixture = fixtures::load_psbt_fixture_with_format_and_namespace(
4222+
network.to_utxolib_name(),
4223+
SignatureState::Fullsigned,
4224+
TxFormat::Psbt,
4225+
FixtureNamespace::UtxolibCompat,
4226+
)
4227+
.expect("Failed to load fixture");
4228+
let mut bitgo_psbt = fixture
4229+
.to_bitgo_psbt(network)
4230+
.expect("Failed to convert to BitGo PSBT");
4231+
4232+
let secp = crate::bitcoin::secp256k1::Secp256k1::new();
4233+
bitgo_psbt
4234+
.finalize_mut(&secp)
4235+
.expect("Failed to finalize PSBT");
4236+
4237+
let default_bytes = bitgo_psbt.clone().extract_tx().expect("default extract");
4238+
4239+
// A high sat/vB ceiling that no normal-fee tx would trip.
4240+
let high_limit_sat_per_vb = ExtractFeePolicy::Limited(
4241+
miniscript::bitcoin::FeeRate::from_sat_per_vb_unchecked(1_000_000_000),
4242+
);
4243+
4244+
assert_eq!(
4245+
bitgo_psbt
4246+
.clone()
4247+
.extract_tx_with_fee_policy(ExtractFeePolicy::Default)
4248+
.expect("Default policy"),
4249+
default_bytes,
4250+
"Default policy must match extract_tx()"
4251+
);
4252+
assert_eq!(
4253+
bitgo_psbt
4254+
.clone()
4255+
.extract_tx_with_fee_policy(ExtractFeePolicy::Unchecked)
4256+
.expect("Unchecked policy"),
4257+
default_bytes,
4258+
"Unchecked policy must match extract_tx() on a normal-fee PSBT"
4259+
);
4260+
assert_eq!(
4261+
bitgo_psbt
4262+
.clone()
4263+
.extract_tx_with_fee_policy(high_limit_sat_per_vb)
4264+
.expect("Limited policy"),
4265+
default_bytes,
4266+
"Limited policy with a high sat/vB ceiling must match extract_tx()"
4267+
);
4268+
}
4269+
41224270
/// Test extract_half_signed_legacy_tx for p2ms-based script types
41234271
fn test_extract_half_signed_legacy_tx_for_script_type(
41244272
network: Network,

packages/wasm-utxo/src/fixed_script_wallet/bitgo_psbt/zcash_psbt.rs

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -161,6 +161,17 @@ impl ZcashBitGoPsbt {
161161
///
162162
/// This method consumes the PSBT to avoid cloning.
163163
pub fn extract_tx(self) -> Result<Vec<u8>, super::DeserializeError> {
164+
self.extract_tx_with_fee_policy(super::ExtractFeePolicy::Default)
165+
}
166+
167+
/// Extract the finalized Zcash transaction bytes from the PSBT with an
168+
/// explicit fee-rate [`policy`][super::ExtractFeePolicy].
169+
///
170+
/// This method consumes the PSBT to avoid cloning.
171+
pub fn extract_tx_with_fee_policy(
172+
self,
173+
policy: super::ExtractFeePolicy,
174+
) -> Result<Vec<u8>, super::DeserializeError> {
164175
use miniscript::bitcoin::psbt::ExtractTxError;
165176

166177
// Capture Zcash-specific fields before consuming psbt
@@ -170,7 +181,7 @@ impl ZcashBitGoPsbt {
170181
let expiry_height = self.expiry_height.unwrap_or(0);
171182
let sapling_fields = self.sapling_fields;
172183

173-
let tx = self.psbt.extract_tx().map_err(|e| match e {
184+
let map_extract_err = |e: ExtractTxError| match e {
174185
ExtractTxError::AbsurdFeeRate { .. } => {
175186
super::DeserializeError::Network(format!("Absurd fee rate: {}", e))
176187
}
@@ -181,7 +192,16 @@ impl ZcashBitGoPsbt {
181192
super::DeserializeError::Network(format!("Sending too much: {}", e))
182193
}
183194
_ => super::DeserializeError::Network(format!("Failed to extract transaction: {}", e)),
184-
})?;
195+
};
196+
197+
let tx = match policy {
198+
super::ExtractFeePolicy::Default => self.psbt.extract_tx().map_err(map_extract_err)?,
199+
super::ExtractFeePolicy::Unchecked => self.psbt.extract_tx_unchecked_fee_rate(),
200+
super::ExtractFeePolicy::Limited(max_fee_rate_sat_per_vb) => self
201+
.psbt
202+
.extract_tx_with_fee_rate_limit(max_fee_rate_sat_per_vb)
203+
.map_err(map_extract_err)?,
204+
};
185205

186206
let parts = crate::zcash::transaction::ZcashTransactionParts {
187207
transaction: tx,

0 commit comments

Comments
 (0)