From d71c75c9ef5ace1bfcfb27f8be69a119bcc23326 Mon Sep 17 00:00:00 2001 From: Otto Allmendinger Date: Tue, 18 Aug 2026 09:21:24 +0200 Subject: [PATCH 1/2] test(wasm-utxo): reproduce P2WSH misclassification with nonstandard derivations Dimensions.fromPsbt classifies inputs using BIP32 derivation chain codes, falling back to P2shP2pk when no standard BitGo chain is found. PSBTs with nonstandard chain codes on valid P2WSH 2-of-3 multisig inputs cause misclassification as non-segwit P2shP2pk. This inflates vsize ~2.5x, understates feeRate, and triggers maxfeerate broadcast rejections (WCN-2155). This test asserts the current (buggy) behavior: a P2WSH PSBT built with nonstandard chain codes (0/1 instead of 20/21) via the WrapPsbt/descriptor API is classified as non-segwit P2shP2pk, producing a larger vsize than the equivalent wallet-built PSBT. Also asserts the P2shP2pk fallback for replay-protection inputs without derivations. Refs: WCN-2155 --- packages/wasm-utxo/test/dimensions.ts | 119 +++++++++++++++++++++++++- 1 file changed, 118 insertions(+), 1 deletion(-) diff --git a/packages/wasm-utxo/test/dimensions.ts b/packages/wasm-utxo/test/dimensions.ts index bdb808d6a86..a6b5e696c7d 100644 --- a/packages/wasm-utxo/test/dimensions.ts +++ b/packages/wasm-utxo/test/dimensions.ts @@ -1,5 +1,6 @@ import assert from "node:assert"; -import { Dimensions, fixedScriptWallet } from "../js/index.js"; +import { Dimensions, Descriptor, ECPair, fixedScriptWallet, Psbt } from "../js/index.js"; +import { formatNode } from "../js/ast/index.js"; import { Transaction } from "../js/transaction.js"; import { loadPsbtFixture, @@ -8,6 +9,7 @@ import { type Output, } from "./fixedScript/fixtureUtil.js"; import { mainnetCoinNames } from "./fixedScript/networkSupport.util.js"; +import { getDefaultWalletKeys } from "../js/testutils/index.js"; import type { InputScriptType } from "../js/fixedScriptWallet/BitGoPsbt.js"; /** @@ -465,6 +467,121 @@ describe("Dimensions", function () { }); describe("fromPsbt", function () { + it("misclassifies P2WSH inputs with nonstandard derivation paths as P2shP2pk", function () { + const rootWalletKeys = getDefaultWalletKeys(); + const xpubs: [string, string, string] = [ + rootWalletKeys.userKey().toBase58(), + rootWalletKeys.backupKey().toBase58(), + rootWalletKeys.bitgoKey().toBase58(), + ]; + + // Build a P2WSH PSBT with full wallet metadata. addWalletInput sets + // witness_script (2-of-3 multisig) + BIP32 derivations. + const walletPsbt = fixedScriptWallet.BitGoPsbt.createEmpty("bitcoin", xpubs); + for (let i = 0; i < 4; i++) { + walletPsbt.addWalletInput( + { txid: (i + 1).toString(16).padStart(64, "0"), vout: 0, value: 100_000n }, + xpubs, + { scriptId: { chain: 20, index: i } }, + ); + } + const outputScript = Buffer.concat([Buffer.from([0x00, 0x20]), Buffer.alloc(32, 0x01)]); + walletPsbt.addOutput(outputScript, 399_000n); + + // Build an equivalent PSBT with nonstandard chain codes using the + // WrapPsbt / descriptor API (same pattern as nonStandardPaths.ts). + // Swan self-signing PSBTs carry witness_script and derivations, but + // with chain codes that don't follow BitGo convention. + const desc = Descriptor.fromString( + formatNode({ + wsh: { + multi: [2, `${xpubs[0]}/0/*`, `${xpubs[1]}/0/*`, `${xpubs[2]}/0/*`], + }, + }), + "derivable", + ); + const externalPsbt = new Psbt(); + for (let i = 0; i < 4; i++) { + const descAt = desc.atDerivationIndex(i); + externalPsbt.addInput( + (i + 1).toString(16).padStart(64, "0"), + 0, + 100_000n, + Buffer.from(descAt.scriptPubkey()), + ); + externalPsbt.updateInputWithDescriptor(i, descAt); + } + externalPsbt.addOutput(outputScript, 399_000n); + + // Wrap as BitGoPsbt for Dimensions.fromPsbt. + const externalBitGoPsbt = fixedScriptWallet.BitGoPsbt.fromBytes( + externalPsbt.serialize(), + "bitcoin", + ); + + // Sanity: external inputs have derivations (nonstandard chain codes) + // and witness_script. + assert.ok( + externalBitGoPsbt.getInputs().every((input) => input.bip32Derivation.length === 3), + "external inputs should have 3 BIP32 derivations each", + ); + + const walletDim = Dimensions.fromPsbt(walletPsbt); + const externalDim = Dimensions.fromPsbt(externalBitGoPsbt); + const p2wshDim = Dimensions.fromInput({ scriptType: "p2wsh" }); + const p2shP2pkDim = Dimensions.fromInput({ scriptType: "p2shP2pk" }); + + // With derivations: correctly classified as P2WSH (segwit). + assert.strictEqual(walletDim.hasSegwit, true); + assert.strictEqual(walletDim.getInputWeight("max"), p2wshDim.getInputWeight("max") * 4); + + // Without standard derivations: incorrectly falls back to P2SH-P2PK + // (non-segwit), inflating vsize and understating fee rate. + assert.strictEqual(externalDim.hasSegwit, false); + assert.strictEqual( + externalDim.getInputWeight("max"), + p2shP2pkDim.getInputWeight("max") * 4, + ); + + // The non-segwit fallback inflates vsize, understating fee rate. + assert.ok( + externalDim.getVSize("max") > walletDim.getVSize("max"), + `external vsize ${externalDim.getVSize("max")} should exceed wallet vsize ${walletDim.getVSize("max")}`, + ); + }); + + it("falls back to P2shP2pk for replay protection input without derivations", function () { + const rootWalletKeys = getDefaultWalletKeys(); + const xpubs: [string, string, string] = [ + rootWalletKeys.userKey().toBase58(), + rootWalletKeys.backupKey().toBase58(), + rootWalletKeys.bitgoKey().toBase58(), + ]; + + // A replay protection key: derive an ECPair from the user key's public key. + const rpEcpair = ECPair.fromPublicKey(Buffer.from(rootWalletKeys.userKey().publicKey)); + + // Build a PSBT with a single replay protection input. addReplayProtectionInput + // sets redeem_script to a P2PK script ( OP_CHECKSIG) and does not add + // any BIP32 derivations, which is exactly the case the fallback must handle. + const psbt = fixedScriptWallet.BitGoPsbt.createEmpty("bitcoin", xpubs); + psbt.addReplayProtectionInput({ txid: "01".repeat(32), vout: 0, value: 100_000n }, rpEcpair); + const outputScript = Buffer.concat([Buffer.from([0x00, 0x20]), Buffer.alloc(32, 0x01)]); + psbt.addOutput(outputScript, 99_000n); + + // Sanity: the RP input has no BIP32 derivations (the fallback case). + const inputs = psbt.getInputs(); + assert.strictEqual(inputs.length, 1); + assert.strictEqual(inputs[0].bip32Derivation.length, 0); + + const dim = Dimensions.fromPsbt(psbt); + const p2shP2pkDim = Dimensions.fromInput({ scriptType: "p2shP2pk" }); + + // The fallback classifies this as P2shP2pk (non-segwit). + assert.strictEqual(dim.hasSegwit, false); + assert.strictEqual(dim.getInputWeight("max"), p2shP2pkDim.getInputWeight("max") * 4); + }); + // Zcash has additional transaction overhead that we don't account for const networksToTest = mainnetCoinNames.filter((n) => n !== "zec"); From 5499ca002de6d7c8528170b0749ce26bb352a49d Mon Sep 17 00:00:00 2001 From: Otto Allmendinger Date: Mon, 17 Aug 2026 15:24:06 +0200 Subject: [PATCH 2/2] fix(wasm-utxo): infer input script type for PSBT inputs with nonstandard derivations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Dimensions.from_psbt unconditionally classified inputs lacking BIP32 derivation paths as P2shP2pk (replay protection), regardless of the actual script type. For P2WSH PSBTs with nonstandard derivation paths this inflated vsize ~2.5x, understating feeRate and triggering maxfeerate broadcast rejections (WCN-2155). Replace the entire classification path with a single call to infer_input_script_type for every input. Classification is now based solely on script metadata: witness_script (verified as 2-of-3 multisig via parse_multisig_script_2_of_3) → P2wsh/P2shP2wsh; redeem_script (P2PK via parse_p2pk_script, or 2-of-3 multisig) → P2shP2pk/P2sh; taproot metadata (tap_scripts/tap_internal_key) → P2trMusig2 variants; is_p2mr output → P2mr. Every candidate is cross-checked against the output-script shape; mismatches and bare inputs (only witness_utxo) error rather than guessing. The old BIP32 derivation-chain-code path is removed entirely — script metadata is sufficient and more authoritative. The P2shP2pk default fallback is gone; replay protection inputs are now correctly detected via their P2PK redeem_script. Refs: WCN-2155 --- packages/wasm-utxo/src/address/mod.rs | 1 + .../bitgo_psbt/psbt_wallet_input.rs | 254 ++++++++++++++++-- .../wasm/fixed_script_wallet/dimensions.rs | 51 +--- packages/wasm-utxo/test/dimensions.ts | 37 ++- 4 files changed, 261 insertions(+), 82 deletions(-) diff --git a/packages/wasm-utxo/src/address/mod.rs b/packages/wasm-utxo/src/address/mod.rs index 3ea3cf05d3f..ca9f18b6460 100644 --- a/packages/wasm-utxo/src/address/mod.rs +++ b/packages/wasm-utxo/src/address/mod.rs @@ -39,6 +39,7 @@ pub mod networks; pub mod utxolib_compat; pub use base58check::Base58CheckCodec; +pub(crate) use bech32::is_p2mr; pub use bech32::Bech32Codec; pub use cashaddr::CashAddrCodec; pub use networks::{ diff --git a/packages/wasm-utxo/src/fixed_script_wallet/bitgo_psbt/psbt_wallet_input.rs b/packages/wasm-utxo/src/fixed_script_wallet/bitgo_psbt/psbt_wallet_input.rs index b3979d83f72..3bc9f6b6600 100644 --- a/packages/wasm-utxo/src/fixed_script_wallet/bitgo_psbt/psbt_wallet_input.rs +++ b/packages/wasm-utxo/src/fixed_script_wallet/bitgo_psbt/psbt_wallet_input.rs @@ -3,9 +3,11 @@ use miniscript::bitcoin::psbt::{Input, Psbt}; use miniscript::bitcoin::secp256k1::{self, PublicKey}; use miniscript::bitcoin::{OutPoint, ScriptBuf, TapLeafHash, XOnlyPublicKey}; +use crate::address::is_p2mr; use crate::bitcoin::bip32::KeySource; use crate::fixed_script_wallet::{ - OutputScriptType, ReplayProtection, RootWalletKeys, ScriptId, WalletOutputScript, + parse_multisig_script_2_of_3, parse_p2pk_script, OutputScriptType, ReplayProtection, + RootWalletKeys, ScriptId, WalletOutputScript, }; use crate::Network; @@ -399,23 +401,6 @@ pub fn get_derivation_paths(input: &Input) -> Vec<&DerivationPath> { } } -pub fn parse_shared_chain_and_index(input: &Input) -> Result<(u32, u32), String> { - use crate::fixed_script_wallet::wallet_scripts::path_chain_index; - - let paths = get_derivation_paths(input); - if paths.is_empty() { - return Err("no derivation paths".to_string()); - } - let (chain, index) = - path_chain_index(paths[0]).ok_or_else(|| "invalid derivation path".to_string())?; - for path in &paths[1..] { - if path_chain_index(path) != Some((chain, index)) { - return Err("inconsistent derivation paths".to_string()); - } - } - Ok((chain, index)) -} - #[derive(Debug, strum::IntoStaticStr)] pub enum OutputScriptError { OutputIndexOutOfBounds { vout: u32 }, @@ -709,6 +694,92 @@ fn get_output_script_from_input( get_output_script_and_value(input, prevout).map(|(script, _value)| script) } +/// Check that the output-script shape is consistent with a candidate type. +fn shape_matches(candidate: InputScriptType, output_script: &ScriptBuf) -> bool { + match candidate { + InputScriptType::P2shP2wsh | InputScriptType::P2sh | InputScriptType::P2shP2pk => { + output_script.is_p2sh() + } + InputScriptType::P2wsh => output_script.is_p2wsh(), + InputScriptType::P2trLegacy + | InputScriptType::P2trMusig2ScriptPath + | InputScriptType::P2trMusig2KeyPath => output_script.is_p2tr(), + InputScriptType::P2mr => is_p2mr(output_script), + } +} + +/// Classify a PSBT input's script type by inspecting `witness_script` / +/// `redeem_script` (and taproot metadata), verifying the script content +/// (2-of-3 multisig, P2PK) and cross-checking the candidate against the +/// output-script shape. Returns `Err` when no script metadata is present. +pub fn infer_input_script_type( + psbt_input: &Input, + prevout: OutPoint, +) -> Result { + let output_script = get_output_script_from_input(psbt_input, prevout) + .map_err(|e| format!("failed to extract output script: {}", e))?; + + // witness_script present ⇒ segwit input; check it first and do not fall + // through to non-segwit redeem_script-only classification. + if let Some(witness_script) = psbt_input.witness_script.as_ref() { + if parse_multisig_script_2_of_3(witness_script).is_ok() { + let candidate = if psbt_input.redeem_script.is_some() { + InputScriptType::P2shP2wsh + } else { + InputScriptType::P2wsh + }; + if shape_matches(candidate, output_script) { + return Ok(candidate); + } + } + // witness_script present but not a recognized 2-of-3 multisig, or shape + // mismatch: cannot classify (do NOT try redeem_script as P2sh — + // witness_script implies segwit). + return Err(format!( + "cannot classify segwit input: witness_script present but unrecognised or shape-mismatched (output {:x})", + output_script + )); + } + + // redeem_script present (witness_script absent) + if let Some(redeem_script) = psbt_input.redeem_script.as_ref() { + if parse_p2pk_script(redeem_script).is_some() { + let candidate = InputScriptType::P2shP2pk; + if shape_matches(candidate, output_script) { + return Ok(candidate); + } + } else if parse_multisig_script_2_of_3(redeem_script).is_ok() { + let candidate = InputScriptType::P2sh; + if shape_matches(candidate, output_script) { + return Ok(candidate); + } + } + return Err(format!( + "cannot classify input: redeem_script present but unrecognised or shape-mismatched (output {:x})", + output_script + )); + } + + // taproot metadata (no witness/redeem script) + if !psbt_input.tap_scripts.is_empty() && output_script.is_p2tr() { + // P2trLegacy and P2trMusig2ScriptPath have identical weights. + return Ok(InputScriptType::P2trMusig2ScriptPath); + } + if psbt_input.tap_internal_key.is_some() && output_script.is_p2tr() { + return Ok(InputScriptType::P2trMusig2KeyPath); + } + + // P2MR: BIP-360 witness v2 program is unambiguous. + if is_p2mr(output_script) { + return Ok(InputScriptType::P2mr); + } + + Err(format!( + "cannot classify input: no witness_script, redeem_script, taproot metadata, or p2mr output (output {:x})", + output_script + )) +} + #[derive(Debug, Clone, PartialEq, Eq, strum::IntoStaticStr)] pub enum InputValidationErrorKind { /// Failed to extract output script from input @@ -1129,3 +1200,150 @@ pub mod test_helpers { assert_psbt_validation_error_eq(&actual_psbt_error, &expected_psbt_error); }, ignore: [BitcoinGold, BitcoinCash, Ecash, Zcash]); } + +#[cfg(test)] +mod infer_tests { + use super::*; + use crate::fixed_script_wallet::wallet_keys::tests::get_test_wallet_keys; + use crate::fixed_script_wallet::wallet_scripts::chain_index_path; + use crate::fixed_script_wallet::{ + build_multisig_script_2_of_3, build_p2pk_script, to_pub_triple, + }; + use miniscript::bitcoin::psbt; + use miniscript::bitcoin::{Amount, TxOut}; + + /// Build a PSBT input with a P2WSH-shaped witness_utxo (output = multisig.to_p2wsh()). + fn p2wsh_input(witness_script: ScriptBuf) -> Input { + psbt::Input { + witness_script: Some(witness_script.clone()), + witness_utxo: Some(TxOut { + value: Amount::from_sat(100_000), + script_pubkey: witness_script.to_p2wsh(), + }), + ..Default::default() + } + } + + /// Build a PSBT input whose witness_utxo output script is `output_script`. + fn input_with_output(output_script: ScriptBuf) -> Input { + psbt::Input { + witness_utxo: Some(TxOut { + value: Amount::from_sat(100_000), + script_pubkey: output_script, + }), + ..Default::default() + } + } + + fn test_pub_triple() -> crate::fixed_script_wallet::PubTriple { + let wallet_keys = get_test_wallet_keys("infer_input_script_type"); + let derived = wallet_keys + .derive_path(&chain_index_path(0, 0)) + .expect("derive path"); + to_pub_triple(&derived) + } + + fn dummy_prevout() -> OutPoint { + OutPoint::new(miniscript::bitcoin::hashes::Hash::all_zeros(), 0) + } + + #[test] + fn tier1_witness_script_2_of_3_no_derivations_is_p2wsh() { + let triple = test_pub_triple(); + let multisig = build_multisig_script_2_of_3(&triple); + let input = p2wsh_input(multisig); + + let result = infer_input_script_type(&input, dummy_prevout()).expect("should classify"); + assert_eq!(result, InputScriptType::P2wsh); + } + + #[test] + fn tier1_witness_script_plus_redeem_script_is_p2shp2wsh() { + let triple = test_pub_triple(); + let multisig = build_multisig_script_2_of_3(&triple); + // P2shP2wsh: witness_script = multisig, redeem_script = P2WSH wrapper, + // output = P2SH of the P2WSH wrapper. + let redeem_script = multisig.to_p2wsh(); + let output_script = redeem_script.to_p2sh(); + let input = psbt::Input { + witness_script: Some(multisig), + redeem_script: Some(redeem_script), + witness_utxo: Some(TxOut { + value: Amount::from_sat(100_000), + script_pubkey: output_script, + }), + ..Default::default() + }; + + let result = infer_input_script_type(&input, dummy_prevout()).expect("should classify"); + assert_eq!(result, InputScriptType::P2shP2wsh); + } + + #[test] + fn tier1_redeem_script_p2pk_no_derivations_is_p2shp2pk() { + let triple = test_pub_triple(); + let p2pk = build_p2pk_script(triple[0]); + let output_script = p2pk.to_p2sh(); + let input = psbt::Input { + redeem_script: Some(p2pk), + witness_utxo: Some(TxOut { + value: Amount::from_sat(100_000), + script_pubkey: output_script, + }), + ..Default::default() + }; + + let result = infer_input_script_type(&input, dummy_prevout()).expect("should classify"); + assert_eq!(result, InputScriptType::P2shP2pk); + } + + #[test] + fn tier1_redeem_script_2_of_3_no_derivations_is_p2sh() { + let triple = test_pub_triple(); + let multisig = build_multisig_script_2_of_3(&triple); + let output_script = multisig.to_p2sh(); + let input = psbt::Input { + redeem_script: Some(multisig), + witness_utxo: Some(TxOut { + value: Amount::from_sat(100_000), + script_pubkey: output_script, + }), + ..Default::default() + }; + + let result = infer_input_script_type(&input, dummy_prevout()).expect("should classify"); + assert_eq!(result, InputScriptType::P2sh); + } + + #[test] + fn bare_input_with_only_witness_utxo_errors() { + let triple = test_pub_triple(); + let output_script = build_multisig_script_2_of_3(&triple).to_p2wsh(); + let input = input_with_output(output_script); + + let result = infer_input_script_type(&input, dummy_prevout()); + assert!(result.is_err(), "expected error for bare input"); + } + + #[test] + fn witness_script_shape_cross_check_failure_errors() { + // witness_script parses as 2-of-3, but output is P2SH (not P2WSH). + let triple = test_pub_triple(); + let multisig = build_multisig_script_2_of_3(&triple); + let p2sh_output = multisig.to_p2sh(); + let input = psbt::Input { + witness_script: Some(multisig), + witness_utxo: Some(TxOut { + value: Amount::from_sat(100_000), + script_pubkey: p2sh_output, + }), + ..Default::default() + }; + + let result = infer_input_script_type(&input, dummy_prevout()); + assert!( + result.is_err(), + "expected error when shape cross-check fails" + ); + } +} diff --git a/packages/wasm-utxo/src/wasm/fixed_script_wallet/dimensions.rs b/packages/wasm-utxo/src/wasm/fixed_script_wallet/dimensions.rs index 4c637eb2fc8..fa93ba67356 100644 --- a/packages/wasm-utxo/src/wasm/fixed_script_wallet/dimensions.rs +++ b/packages/wasm-utxo/src/wasm/fixed_script_wallet/dimensions.rs @@ -7,7 +7,7 @@ use std::str::FromStr; use crate::error::WasmUtxoError; use crate::fixed_script_wallet::bitgo_psbt::psbt_wallet_input::{ - parse_shared_chain_and_index, InputScriptType, + infer_input_script_type, InputScriptType, }; use crate::fixed_script_wallet::wallet_scripts::OutputScriptType; use crate::fixed_script_wallet::Chain; @@ -404,8 +404,10 @@ impl WasmDimensions { /// Create dimensions from a BitGoPsbt /// /// Parses PSBT inputs and outputs to compute weight bounds without - /// requiring wallet keys. Input types are detected from BIP32 derivation - /// paths stored in the PSBT. + /// requiring wallet keys. Each input is classified by inspecting its + /// `witness_script` / `redeem_script` (and taproot metadata), so BitGo + /// wallet inputs, replay-protection inputs, and externally-signed inputs + /// are all detected correctly. pub fn from_psbt(psbt: &BitGoPsbt) -> Result { let inner_psbt = psbt.psbt.psbt(); let unsigned_tx = &inner_psbt.unsigned_tx; @@ -416,45 +418,10 @@ impl WasmDimensions { // Process inputs for (i, psbt_input) in inner_psbt.inputs.iter().enumerate() { - // Try to get chain from derivation paths - let weights = match parse_shared_chain_and_index(psbt_input) { - Ok((chain, _index)) => { - // Determine script type from chain and PSBT input metadata - let chain_enum = Chain::try_from(chain).map_err(|e| { - WasmUtxoError::new(&format!( - "Invalid chain {} at input {}: {}", - chain, i, e - )) - })?; - - // For p2trMusig2, check if it's keypath or scriptpath - let script_type = match chain_enum.script_type { - OutputScriptType::P2sh => InputScriptType::P2sh, - OutputScriptType::P2shP2wsh => InputScriptType::P2shP2wsh, - OutputScriptType::P2wsh => InputScriptType::P2wsh, - OutputScriptType::P2trLegacy => InputScriptType::P2trLegacy, - OutputScriptType::P2trMusig2 => { - // Check if tap_scripts are populated to distinguish keypath/scriptpath - if !psbt_input.tap_script_sigs.is_empty() - || !psbt_input.tap_scripts.is_empty() - { - InputScriptType::P2trMusig2ScriptPath - } else { - InputScriptType::P2trMusig2KeyPath - } - } - OutputScriptType::P2mr => InputScriptType::P2mr, - }; - - get_input_weights_for_type(script_type, false) - } - Err(_) => { - // No derivation path - check if it's a replay protection input - // Replay protection inputs have unknownKeyVals with specific markers - // For now, assume p2shP2pk for inputs without derivation paths - get_input_weights_for_type(InputScriptType::P2shP2pk, false) - } - }; + let prevout = unsigned_tx.input[i].previous_output; + let script_type = infer_input_script_type(psbt_input, prevout) + .map_err(|e| WasmUtxoError::new(&format!("Cannot classify input {}: {}", i, e)))?; + let weights = get_input_weights_for_type(script_type, false); input_weight_min += weights.min; input_weight_max += weights.max; diff --git a/packages/wasm-utxo/test/dimensions.ts b/packages/wasm-utxo/test/dimensions.ts index a6b5e696c7d..ef54d4a74d7 100644 --- a/packages/wasm-utxo/test/dimensions.ts +++ b/packages/wasm-utxo/test/dimensions.ts @@ -467,7 +467,7 @@ describe("Dimensions", function () { }); describe("fromPsbt", function () { - it("misclassifies P2WSH inputs with nonstandard derivation paths as P2shP2pk", function () { + it("correctly classifies P2WSH inputs with nonstandard derivation paths", function () { const rootWalletKeys = getDefaultWalletKeys(); const xpubs: [string, string, string] = [ rootWalletKeys.userKey().toBase58(), @@ -490,8 +490,9 @@ describe("Dimensions", function () { // Build an equivalent PSBT with nonstandard chain codes using the // WrapPsbt / descriptor API (same pattern as nonStandardPaths.ts). - // Swan self-signing PSBTs carry witness_script and derivations, but - // with chain codes that don't follow BitGo convention. + // Externally-built PSBTs may carry witness_script and derivations, but + // with chain codes that don't follow BitGo convention. The classifier + // must detect P2WSH from witness_script regardless of chain codes. const desc = Descriptor.fromString( formatNode({ wsh: { @@ -520,7 +521,7 @@ describe("Dimensions", function () { ); // Sanity: external inputs have derivations (nonstandard chain codes) - // and witness_script. + // and witness_script; the classifier fires on witness_script. assert.ok( externalBitGoPsbt.getInputs().every((input) => input.bip32Derivation.length === 3), "external inputs should have 3 BIP32 derivations each", @@ -529,28 +530,19 @@ describe("Dimensions", function () { const walletDim = Dimensions.fromPsbt(walletPsbt); const externalDim = Dimensions.fromPsbt(externalBitGoPsbt); const p2wshDim = Dimensions.fromInput({ scriptType: "p2wsh" }); - const p2shP2pkDim = Dimensions.fromInput({ scriptType: "p2shP2pk" }); - // With derivations: correctly classified as P2WSH (segwit). + // Both wallet and external PSBTs are classified as P2WSH (segwit) + // via witness_script parsing, regardless of derivation chain codes. assert.strictEqual(walletDim.hasSegwit, true); + assert.strictEqual(externalDim.hasSegwit, true); assert.strictEqual(walletDim.getInputWeight("max"), p2wshDim.getInputWeight("max") * 4); + assert.strictEqual(externalDim.getInputWeight("max"), p2wshDim.getInputWeight("max") * 4); - // Without standard derivations: incorrectly falls back to P2SH-P2PK - // (non-segwit), inflating vsize and understating fee rate. - assert.strictEqual(externalDim.hasSegwit, false); - assert.strictEqual( - externalDim.getInputWeight("max"), - p2shP2pkDim.getInputWeight("max") * 4, - ); - - // The non-segwit fallback inflates vsize, understating fee rate. - assert.ok( - externalDim.getVSize("max") > walletDim.getVSize("max"), - `external vsize ${externalDim.getVSize("max")} should exceed wallet vsize ${walletDim.getVSize("max")}`, - ); + // Dimensions are identical because the script type is the same. + assert.strictEqual(externalDim.getInputWeight("max"), walletDim.getInputWeight("max")); }); - it("falls back to P2shP2pk for replay protection input without derivations", function () { + it("correctly classifies P2SH-P2PK replay protection input without BIP32 derivations", function () { const rootWalletKeys = getDefaultWalletKeys(); const xpubs: [string, string, string] = [ rootWalletKeys.userKey().toBase58(), @@ -577,9 +569,10 @@ describe("Dimensions", function () { const dim = Dimensions.fromPsbt(psbt); const p2shP2pkDim = Dimensions.fromInput({ scriptType: "p2shP2pk" }); - // The fallback classifies this as P2shP2pk (non-segwit). + // The P2PK redeem-script shape check classifies this as P2shP2pk (non-segwit), + // validated from the redeem_script rather than assumed by default. assert.strictEqual(dim.hasSegwit, false); - assert.strictEqual(dim.getInputWeight("max"), p2shP2pkDim.getInputWeight("max") * 4); + assert.strictEqual(dim.getInputWeight("max"), p2shP2pkDim.getInputWeight("max")); }); // Zcash has additional transaction overhead that we don't account for