From f68729d9ddeb56ea424b04cec822c3a75761dc72 Mon Sep 17 00:00:00 2001 From: Otto Allmendinger Date: Tue, 13 Jan 2026 11:31:35 +0100 Subject: [PATCH] feat(wasm-utxo): optimize output dimensions calculation Add new methods to optimize output weight estimation: - Support output dimensions by length only, avoiding script generation - Add output dimensions by script type for common patterns - Use script length instead of full script bytes in fromOutput() Issue: BTC-2934 Co-authored-by: llm-git --- .../js/fixedScriptWallet/Dimensions.ts | 26 ++++++-- .../wasm/fixed_script_wallet/dimensions.rs | 25 ++++++- packages/wasm-utxo/test/dimensions.ts | 65 ++++++++++++++++++- 3 files changed, 107 insertions(+), 9 deletions(-) diff --git a/packages/wasm-utxo/js/fixedScriptWallet/Dimensions.ts b/packages/wasm-utxo/js/fixedScriptWallet/Dimensions.ts index 02c8395..d382247 100644 --- a/packages/wasm-utxo/js/fixedScriptWallet/Dimensions.ts +++ b/packages/wasm-utxo/js/fixedScriptWallet/Dimensions.ts @@ -1,6 +1,7 @@ import { WasmDimensions } from "../wasm/wasm_utxo.js"; import type { BitGoPsbt, InputScriptType, SignPath } from "./BitGoPsbt.js"; import type { CoinName } from "../coinName.js"; +import type { OutputScriptType } from "./scriptType.js"; import { toOutputScriptWithCoin } from "../address.js"; type FromInputParams = { chain: number; signPath?: SignPath } | { scriptType: InputScriptType }; @@ -56,15 +57,30 @@ export class Dimensions { * Create dimensions for a single output from an address */ static fromOutput(address: string, network: CoinName): Dimensions; - static fromOutput(scriptOrAddress: Uint8Array | string, network?: CoinName): Dimensions { - if (typeof scriptOrAddress === "string") { + /** + * Create dimensions for a single output from script length only + */ + static fromOutput(params: { length: number }): Dimensions; + /** + * Create dimensions for a single output from script type + */ + static fromOutput(params: { scriptType: OutputScriptType }): Dimensions; + static fromOutput( + params: Uint8Array | string | { length: number } | { scriptType: OutputScriptType }, + network?: CoinName, + ): Dimensions { + if (typeof params === "string") { if (network === undefined) { throw new Error("network is required when passing an address string"); } - const script = toOutputScriptWithCoin(scriptOrAddress, network); - return new Dimensions(WasmDimensions.from_output_script(script)); + const script = toOutputScriptWithCoin(params, network); + return new Dimensions(WasmDimensions.from_output_script_length(script.length)); + } + if (typeof params === "object" && "scriptType" in params) { + return new Dimensions(WasmDimensions.from_output_script_type(params.scriptType)); } - return new Dimensions(WasmDimensions.from_output_script(scriptOrAddress)); + // Both Uint8Array and { length: number } have .length + return new Dimensions(WasmDimensions.from_output_script_length(params.length)); } /** 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 1b9a27b..8ae5984 100644 --- a/packages/wasm-utxo/src/wasm/fixed_script_wallet/dimensions.rs +++ b/packages/wasm-utxo/src/wasm/fixed_script_wallet/dimensions.rs @@ -3,6 +3,8 @@ //! This module provides weight-based estimation for transaction fees, //! tracking min/max bounds to account for ECDSA signature variance. +use std::str::FromStr; + use crate::error::WasmUtxoError; use crate::fixed_script_wallet::bitgo_psbt::psbt_wallet_input::{ parse_shared_chain_and_index, InputScriptType, @@ -435,9 +437,9 @@ impl WasmDimensions { }) } - /// Create dimensions for a single output from script bytes - pub fn from_output_script(script: &[u8]) -> WasmDimensions { - let weight = compute_output_weight(script.len()); + /// Create dimensions for a single output from script length + pub fn from_output_script_length(length: u32) -> WasmDimensions { + let weight = compute_output_weight(length as usize); WasmDimensions { input_weight_min: 0, input_weight_max: 0, @@ -446,6 +448,23 @@ impl WasmDimensions { } } + /// Create dimensions for a single output from script type string + /// + /// # Arguments + /// * `script_type` - One of: "p2sh", "p2shP2wsh", "p2wsh", "p2tr"/"p2trLegacy", "p2trMusig2" + pub fn from_output_script_type(script_type: &str) -> Result { + let parsed = OutputScriptType::from_str(script_type).map_err(|e| WasmUtxoError::new(&e))?; + let length = match parsed { + // P2SH: OP_HASH160 [20 bytes] OP_EQUAL = 23 bytes + OutputScriptType::P2sh | OutputScriptType::P2shP2wsh => 23, + // P2WSH: OP_0 [32 bytes] = 34 bytes + OutputScriptType::P2wsh => 34, + // P2TR: OP_1 [32 bytes] = 34 bytes + OutputScriptType::P2trLegacy | OutputScriptType::P2trMusig2 => 34, + }; + Ok(Self::from_output_script_length(length)) + } + /// Combine with another Dimensions instance pub fn plus(&self, other: &WasmDimensions) -> WasmDimensions { WasmDimensions { diff --git a/packages/wasm-utxo/test/dimensions.ts b/packages/wasm-utxo/test/dimensions.ts index f5a591e..cbc1c08 100644 --- a/packages/wasm-utxo/test/dimensions.ts +++ b/packages/wasm-utxo/test/dimensions.ts @@ -173,10 +173,73 @@ describe("Dimensions", function () { it("should throw when address is provided without network", function () { assert.throws(() => { - // @ts-expect-error - testing runtime error + // String matches { length: number } but implementation detects string and throws Dimensions.fromOutput("bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4"); }, /network is required/); }); + + it("should create dimensions from script length only", function () { + // Compare with actual script + const script = Buffer.alloc(23); + const fromScript = Dimensions.fromOutput(script); + const fromLength = Dimensions.fromOutput({ length: 23 }); + + assert.strictEqual(fromLength.getWeight(), fromScript.getWeight()); + assert.strictEqual(fromLength.getVSize(), fromScript.getVSize()); + assert.strictEqual(fromLength.getOutputWeight(), fromScript.getOutputWeight()); + }); + + it("should calculate correct weight for different script lengths", function () { + // p2pkh: 25 bytes -> weight = 4 * (8 + 1 + 25) = 136 + const p2pkh = Dimensions.fromOutput({ length: 25 }); + assert.strictEqual(p2pkh.getOutputWeight(), 136); + + // p2wpkh: 22 bytes -> weight = 4 * (8 + 1 + 22) = 124 + const p2wpkh = Dimensions.fromOutput({ length: 22 }); + assert.strictEqual(p2wpkh.getOutputWeight(), 124); + + // p2tr: 34 bytes -> weight = 4 * (8 + 1 + 34) = 172 + const p2tr = Dimensions.fromOutput({ length: 34 }); + assert.strictEqual(p2tr.getOutputWeight(), 172); + }); + + it("should create dimensions from script type", function () { + // p2sh/p2shP2wsh: 23 bytes -> weight = 4 * (8 + 1 + 23) = 128 + const p2sh = Dimensions.fromOutput({ scriptType: "p2sh" }); + assert.strictEqual(p2sh.getOutputWeight(), 128); + + const p2shP2wsh = Dimensions.fromOutput({ scriptType: "p2shP2wsh" }); + assert.strictEqual(p2shP2wsh.getOutputWeight(), 128); + + // p2wsh: 34 bytes -> weight = 4 * (8 + 1 + 34) = 172 + const p2wsh = Dimensions.fromOutput({ scriptType: "p2wsh" }); + assert.strictEqual(p2wsh.getOutputWeight(), 172); + + // p2tr/p2trLegacy: 34 bytes -> weight = 4 * (8 + 1 + 34) = 172 + const p2tr = Dimensions.fromOutput({ scriptType: "p2tr" }); + assert.strictEqual(p2tr.getOutputWeight(), 172); + + const p2trLegacy = Dimensions.fromOutput({ scriptType: "p2trLegacy" }); + assert.strictEqual(p2trLegacy.getOutputWeight(), 172); + + // p2trMusig2: 34 bytes -> weight = 4 * (8 + 1 + 34) = 172 + const p2trMusig2 = Dimensions.fromOutput({ scriptType: "p2trMusig2" }); + assert.strictEqual(p2trMusig2.getOutputWeight(), 172); + }); + + it("scriptType should match equivalent length", function () { + // p2sh = 23 bytes + assert.strictEqual( + Dimensions.fromOutput({ scriptType: "p2sh" }).getOutputWeight(), + Dimensions.fromOutput({ length: 23 }).getOutputWeight(), + ); + + // p2wsh = 34 bytes + assert.strictEqual( + Dimensions.fromOutput({ scriptType: "p2wsh" }).getOutputWeight(), + Dimensions.fromOutput({ length: 34 }).getOutputWeight(), + ); + }); }); describe("plus", function () {