diff --git a/packages/wasm-utxo/js/fixedScriptWallet/BitGoPsbt.ts b/packages/wasm-utxo/js/fixedScriptWallet/BitGoPsbt.ts index 811330b0bb5..90463a59f0a 100644 --- a/packages/wasm-utxo/js/fixedScriptWallet/BitGoPsbt.ts +++ b/packages/wasm-utxo/js/fixedScriptWallet/BitGoPsbt.ts @@ -105,7 +105,15 @@ export type AddWalletOutputOptions = { }; export class BitGoPsbt { - protected constructor(protected wasm: WasmBitGoPsbt) {} + protected constructor(protected _wasm: WasmBitGoPsbt) {} + + /** + * Get the underlying WASM instance + * @internal - for use by other wasm-utxo modules + */ + get wasm(): WasmBitGoPsbt { + return this._wasm; + } /** * Create an empty PSBT for the given network with wallet keys @@ -135,13 +143,13 @@ export class BitGoPsbt { options?: CreateEmptyOptions, ): BitGoPsbt { const keys = RootWalletKeys.from(walletKeys); - const wasm = WasmBitGoPsbt.create_empty( + const wasmPsbt = WasmBitGoPsbt.create_empty( network, keys.wasm, options?.version, options?.lockTime, ); - return new BitGoPsbt(wasm); + return new BitGoPsbt(wasmPsbt); } /** @@ -175,7 +183,7 @@ export class BitGoPsbt { * ``` */ addInput(options: AddInputOptions, script: Uint8Array): number { - return this.wasm.add_input( + return this._wasm.add_input( options.txid, options.vout, options.value, @@ -200,7 +208,7 @@ export class BitGoPsbt { * ``` */ addOutput(options: AddOutputOptions): number { - return this.wasm.add_output(options.script, options.value); + return this._wasm.add_output(options.script, options.value); } /** @@ -248,7 +256,7 @@ export class BitGoPsbt { walletOptions: AddWalletInputOptions, ): number { const keys = RootWalletKeys.from(walletKeys); - return this.wasm.add_wallet_input( + return this._wasm.add_wallet_input( inputOptions.txid, inputOptions.vout, inputOptions.value, @@ -294,7 +302,7 @@ export class BitGoPsbt { */ addWalletOutput(walletKeys: WalletKeysArg, options: AddWalletOutputOptions): number { const keys = RootWalletKeys.from(walletKeys); - return this.wasm.add_wallet_output(options.chain, options.index, options.value, keys.wasm); + return this._wasm.add_wallet_output(options.chain, options.index, options.value, keys.wasm); } /** @@ -318,7 +326,7 @@ export class BitGoPsbt { */ addReplayProtectionInput(inputOptions: AddInputOptions, key: ECPairArg): number { const ecpair = ECPair.from(key); - return this.wasm.add_replay_protection_input( + return this._wasm.add_replay_protection_input( ecpair.wasm, inputOptions.txid, inputOptions.vout, @@ -332,7 +340,7 @@ export class BitGoPsbt { * @returns The unsigned transaction ID */ unsignedTxid(): string { - return this.wasm.unsigned_txid(); + return this._wasm.unsigned_txid(); } /** @@ -340,7 +348,7 @@ export class BitGoPsbt { * @returns The transaction version number */ get version(): number { - return this.wasm.version(); + return this._wasm.version(); } /** @@ -348,7 +356,7 @@ export class BitGoPsbt { * @returns The transaction lock time */ get lockTime(): number { - return this.wasm.lock_time(); + return this._wasm.lock_time(); } /** @@ -364,9 +372,9 @@ export class BitGoPsbt { payGoPubkeys?: ECPairArg[], ): ParsedTransaction { const keys = RootWalletKeys.from(walletKeys); - const rp = ReplayProtection.from(replayProtection, this.wasm.network()); + const rp = ReplayProtection.from(replayProtection, this._wasm.network()); const pubkeys = payGoPubkeys?.map((arg) => ECPair.from(arg).wasm); - return this.wasm.parse_transaction_with_wallet_keys( + return this._wasm.parse_transaction_with_wallet_keys( keys.wasm, rp.wasm, pubkeys, @@ -391,7 +399,7 @@ export class BitGoPsbt { ): ParsedOutput[] { const keys = RootWalletKeys.from(walletKeys); const pubkeys = payGoPubkeys?.map((arg) => ECPair.from(arg).wasm); - return this.wasm.parse_outputs_with_wallet_keys(keys.wasm, pubkeys) as ParsedOutput[]; + return this._wasm.parse_outputs_with_wallet_keys(keys.wasm, pubkeys) as ParsedOutput[]; } /** @@ -406,7 +414,7 @@ export class BitGoPsbt { * @throws Error if output index is out of bounds or entropy is not 64 bytes */ addPayGoAttestation(outputIndex: number, entropy: Uint8Array, signature: Uint8Array): void { - this.wasm.add_paygo_attestation(outputIndex, entropy, signature); + this._wasm.add_paygo_attestation(outputIndex, entropy, signature); } /** @@ -448,12 +456,12 @@ export class BitGoPsbt { // Try to parse as BIP32Arg first (string or BIP32 instance) if (typeof key === "string" || ("derive" in key && typeof key.derive === "function")) { const wasmKey = BIP32.from(key as BIP32Arg).wasm; - return this.wasm.verify_signature_with_xpub(inputIndex, wasmKey); + return this._wasm.verify_signature_with_xpub(inputIndex, wasmKey); } // Otherwise it's an ECPairArg (Uint8Array, ECPair, or WasmECPair) const wasmECPair = ECPair.from(key as ECPairArg).wasm; - return this.wasm.verify_signature_with_pub(inputIndex, wasmECPair); + return this._wasm.verify_signature_with_pub(inputIndex, wasmECPair); } /** @@ -508,11 +516,11 @@ export class BitGoPsbt { ) { // It's a BIP32Arg const wasmKey = BIP32.from(key as BIP32Arg); - this.wasm.sign_with_xpriv(inputIndex, wasmKey.wasm); + this._wasm.sign_with_xpriv(inputIndex, wasmKey.wasm); } else { // It's an ECPairArg const wasmKey = ECPair.from(key as ECPairArg); - this.wasm.sign_with_privkey(inputIndex, wasmKey.wasm); + this._wasm.sign_with_privkey(inputIndex, wasmKey.wasm); } } @@ -540,8 +548,8 @@ export class BitGoPsbt { inputIndex: number, replayProtection: ReplayProtectionArg, ): boolean { - const rp = ReplayProtection.from(replayProtection, this.wasm.network()); - return this.wasm.verify_replay_protection_signature(inputIndex, rp.wasm); + const rp = ReplayProtection.from(replayProtection, this._wasm.network()); + return this._wasm.verify_replay_protection_signature(inputIndex, rp.wasm); } /** @@ -550,7 +558,7 @@ export class BitGoPsbt { * @returns The serialized PSBT as a byte array */ serialize(): Uint8Array { - return this.wasm.serialize(); + return this._wasm.serialize(); } /** @@ -593,7 +601,7 @@ export class BitGoPsbt { */ generateMusig2Nonces(key: BIP32Arg, sessionId?: Uint8Array): void { const wasmKey = BIP32.from(key); - this.wasm.generate_musig2_nonces(wasmKey.wasm, sessionId); + this._wasm.generate_musig2_nonces(wasmKey.wasm, sessionId); } /** @@ -616,7 +624,7 @@ export class BitGoPsbt { * ``` */ combineMusig2Nonces(sourcePsbt: BitGoPsbt): void { - this.wasm.combine_musig2_nonces(sourcePsbt.wasm); + this._wasm.combine_musig2_nonces(sourcePsbt.wasm); } /** @@ -625,7 +633,7 @@ export class BitGoPsbt { * @throws Error if any input failed to finalize */ finalizeAllInputs(): void { - this.wasm.finalize_all_inputs(); + this._wasm.finalize_all_inputs(); } /** @@ -635,6 +643,6 @@ export class BitGoPsbt { * @throws Error if the PSBT is not fully finalized or extraction fails */ extractTransaction(): Uint8Array { - return this.wasm.extract_transaction(); + return this._wasm.extract_transaction(); } } diff --git a/packages/wasm-utxo/js/fixedScriptWallet/Dimensions.ts b/packages/wasm-utxo/js/fixedScriptWallet/Dimensions.ts new file mode 100644 index 00000000000..4b4e8ff5234 --- /dev/null +++ b/packages/wasm-utxo/js/fixedScriptWallet/Dimensions.ts @@ -0,0 +1,99 @@ +import { WasmDimensions } from "../wasm/wasm_utxo.js"; +import type { BitGoPsbt, InputScriptType, SignPath } from "./BitGoPsbt.js"; +import type { CoinName } from "../coinName.js"; +import { toOutputScriptWithCoin } from "../address.js"; + +type FromInputParams = { chain: number; signPath?: SignPath } | { scriptType: InputScriptType }; + +/** + * Dimensions class for estimating transaction virtual size. + * + * Tracks weight internally with min/max bounds to handle ECDSA signature variance. + * Schnorr signatures have no variance (always 64 bytes). + * + * This is a thin wrapper over the WASM implementation. + */ +export class Dimensions { + private constructor(private _wasm: WasmDimensions) {} + + /** + * Create empty dimensions (zero weight) + */ + static empty(): Dimensions { + return new Dimensions(WasmDimensions.empty()); + } + + /** + * 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. + */ + static fromPsbt(psbt: BitGoPsbt): Dimensions { + return new Dimensions(WasmDimensions.from_psbt(psbt.wasm)); + } + + /** + * Create dimensions for a single input + * + * @param params - Either `{ chain, signPath? }` or `{ scriptType }` + */ + static fromInput(params: FromInputParams): Dimensions { + if ("scriptType" in params) { + return new Dimensions(WasmDimensions.from_input_script_type(params.scriptType)); + } + return new Dimensions( + WasmDimensions.from_input(params.chain, params.signPath?.signer, params.signPath?.cosigner), + ); + } + + /** + * Create dimensions for a single output from script bytes + */ + static fromOutput(script: Uint8Array): 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") { + 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)); + } + return new Dimensions(WasmDimensions.from_output_script(scriptOrAddress)); + } + + /** + * Combine with another Dimensions instance + */ + plus(other: Dimensions): Dimensions { + return new Dimensions(this._wasm.plus(other._wasm)); + } + + /** + * Whether any inputs are segwit (affects overhead calculation) + */ + get hasSegwit(): boolean { + return this._wasm.has_segwit(); + } + + /** + * Get total weight (min or max) + * @param size - "min" or "max", defaults to "max" + */ + getWeight(size: "min" | "max" = "max"): number { + return this._wasm.get_weight(size); + } + + /** + * Get virtual size (min or max) + * @param size - "min" or "max", defaults to "max" + */ + getVSize(size: "min" | "max" = "max"): number { + return this._wasm.get_vsize(size); + } +} diff --git a/packages/wasm-utxo/js/fixedScriptWallet/index.ts b/packages/wasm-utxo/js/fixedScriptWallet/index.ts index ad275237dc5..62c02aef90e 100644 --- a/packages/wasm-utxo/js/fixedScriptWallet/index.ts +++ b/packages/wasm-utxo/js/fixedScriptWallet/index.ts @@ -1,6 +1,7 @@ export { RootWalletKeys, type WalletKeysArg, type IWalletKeys } from "./RootWalletKeys.js"; export { ReplayProtection, type ReplayProtectionArg } from "./ReplayProtection.js"; export { outputScript, address } from "./address.js"; +export { Dimensions } from "./Dimensions.js"; // Bitcoin-like PSBT (for all non-Zcash networks) export { diff --git a/packages/wasm-utxo/js/index.ts b/packages/wasm-utxo/js/index.ts index b30b32e3075..19d9a9e986b 100644 --- a/packages/wasm-utxo/js/index.ts +++ b/packages/wasm-utxo/js/index.ts @@ -16,6 +16,7 @@ export * as ecpair from "./ecpair.js"; // Only the most commonly used classes and types are exported at the top level for convenience export { ECPair } from "./ecpair.js"; export { BIP32 } from "./bip32.js"; +export { Dimensions } from "./fixedScriptWallet/Dimensions.js"; export type { CoinName } from "./coinName.js"; export type { Triple } from "./triple.js"; diff --git a/packages/wasm-utxo/js/transaction.ts b/packages/wasm-utxo/js/transaction.ts index 1eebb63b65d..90710e22906 100644 --- a/packages/wasm-utxo/js/transaction.ts +++ b/packages/wasm-utxo/js/transaction.ts @@ -16,6 +16,17 @@ export class Transaction { return this._wasm.to_bytes(); } + /** + * Get the virtual size of the transaction + * + * Virtual size accounts for the segwit discount on witness data. + * + * @returns The virtual size in virtual bytes (vbytes) + */ + getVSize(): number { + return this._wasm.get_vsize(); + } + /** * @internal */ diff --git a/packages/wasm-utxo/src/wasm/fixed_script_wallet/dimensions.rs b/packages/wasm-utxo/src/wasm/fixed_script_wallet/dimensions.rs new file mode 100644 index 00000000000..3705fd82679 --- /dev/null +++ b/packages/wasm-utxo/src/wasm/fixed_script_wallet/dimensions.rs @@ -0,0 +1,512 @@ +//! Dimensions for estimating transaction virtual size. +//! +//! This module provides weight-based estimation for transaction fees, +//! tracking min/max bounds to account for ECDSA signature variance. + +use crate::error::WasmUtxoError; +use crate::fixed_script_wallet::bitgo_psbt::psbt_wallet_input::{ + parse_shared_chain_and_index, InputScriptType, +}; +use crate::fixed_script_wallet::wallet_scripts::Chain; +use miniscript::bitcoin::VarInt; +use wasm_bindgen::prelude::*; + +use super::BitGoPsbt; + +// ============================================================================ +// Weight calculation constants +// ============================================================================ + +// ECDSA signature sizes (DER encoding variance) +const ECDSA_SIG_MIN: usize = 71; +const ECDSA_SIG_MAX: usize = 73; + +// Schnorr signature (fixed size, no sighash byte in witness) +const SCHNORR_SIG: usize = 64; + +// Script constants +const OP_SIZE: usize = 1; +const OP_0_SIZE: usize = OP_SIZE; +const OP_PUSH_SIZE: usize = OP_SIZE; +const OP_CHECKSIG_SIZE: usize = OP_SIZE; +const OP_CHECKSIGVERIFY_SIZE: usize = OP_SIZE; + +// Public key sizes +const SCHNORR_PUBKEY_SIZE: usize = 32; +const P2MS_PUB_SCRIPT_SIZE: usize = 105; // 2-of-3 multisig script with compressed pubkeys +const P2WSH_PUB_SCRIPT_SIZE: usize = 34; +const P2PK_PUB_SCRIPT_SIZE: usize = 35; + +// Transaction overhead +const TX_OVERHEAD_SIZE: usize = 10; // version(4) + locktime(4) + varint for ins(1) + varint for outs(1) +const TX_SEGWIT_OVERHEAD_SIZE: usize = 11; // adds marker(1) + flag(1), but witness varint saves 1 + +// ============================================================================ +// Weight calculation helpers +// ============================================================================ + +/// Compute the size of a length-prefixed slice (varint + data) +fn var_slice_size(length: usize) -> usize { + VarInt::from(length).size() + length +} + +/// Compute the size of a witness vector +fn vector_size(element_lengths: &[usize]) -> usize { + VarInt::from(element_lengths.len()).size() + + element_lengths + .iter() + .map(|&len| var_slice_size(len)) + .sum::() +} + +/// Compute input weight from script and witness component lengths +fn compute_input_weight(script_components: &[usize], witness_components: &[usize]) -> usize { + let script_length: usize = script_components.iter().sum(); + // Base size: prevout(32) + index(4) + sequence(4) + scriptSig + let base_size = 40 + var_slice_size(script_length); + // Witness size (only counted once in weight) + let witness_size = if witness_components.is_empty() { + 0 + } else { + vector_size(witness_components) + }; + // Weight = 3 * base + (base + witness) + 3 * base_size + base_size + witness_size +} + +// ============================================================================ +// Input weight definitions +// ============================================================================ + +struct InputWeights { + min: usize, + max: usize, + is_segwit: bool, +} + +/// Get p2sh 2-of-3 multisig input components +fn get_p2sh_components(sig_size: usize) -> Vec { + vec![ + OP_0_SIZE, + OP_PUSH_SIZE + sig_size, // sig 1 + OP_PUSH_SIZE + sig_size, // sig 2 + OP_PUSH_SIZE + 1 + P2MS_PUB_SCRIPT_SIZE, // OP_PUSHDATA1 + redeemScript + ] +} + +/// Get p2sh-p2wsh 2-of-3 multisig input components +fn get_p2sh_p2wsh_components(sig_size: usize) -> (Vec, Vec) { + ( + vec![OP_SIZE + P2WSH_PUB_SCRIPT_SIZE], + vec![ + 0, // OP_0 placeholder in witness + sig_size, + sig_size, + P2MS_PUB_SCRIPT_SIZE, + ], + ) +} + +/// Get p2wsh 2-of-3 multisig input components +fn get_p2wsh_components(sig_size: usize) -> (Vec, Vec) { + ( + vec![], + vec![ + 0, // OP_0 placeholder + sig_size, + sig_size, + P2MS_PUB_SCRIPT_SIZE, + ], + ) +} + +/// Get p2tr script path spend components (2-of-2 Schnorr in tapleaf) +fn get_p2tr_script_path_components(level: usize) -> (Vec, Vec) { + let leaf_script = OP_PUSH_SIZE + + SCHNORR_PUBKEY_SIZE + + OP_CHECKSIG_SIZE + + OP_PUSH_SIZE + + SCHNORR_PUBKEY_SIZE + + OP_CHECKSIGVERIFY_SIZE; + let control_block = 1 + 32 + 32 * level; // header(1) + internalKey(32) + path(32 * level) + ( + vec![], + vec![SCHNORR_SIG, SCHNORR_SIG, leaf_script, control_block], + ) +} + +/// Get p2tr keypath spend components (single aggregated Schnorr signature) +fn get_p2tr_keypath_components() -> (Vec, Vec) { + (vec![], vec![SCHNORR_SIG]) +} + +/// Get p2sh-p2pk input components (single signature, used for replay protection) +fn get_p2sh_p2pk_components(sig_size: usize) -> Vec { + vec![ + OP_PUSH_SIZE + sig_size, // signature + OP_PUSH_SIZE + P2PK_PUB_SCRIPT_SIZE, // redeemScript (pubkey + OP_CHECKSIG) + ] +} + +/// Get input weight range for a given script type +fn get_input_weights_for_type(script_type: InputScriptType) -> InputWeights { + match script_type { + InputScriptType::P2sh => { + let min = compute_input_weight(&get_p2sh_components(ECDSA_SIG_MIN), &[]); + let max = compute_input_weight(&get_p2sh_components(ECDSA_SIG_MAX), &[]); + InputWeights { + min, + max, + is_segwit: false, + } + } + InputScriptType::P2shP2wsh => { + let (script_min, witness_min) = get_p2sh_p2wsh_components(ECDSA_SIG_MIN); + let (script_max, witness_max) = get_p2sh_p2wsh_components(ECDSA_SIG_MAX); + let min = compute_input_weight(&script_min, &witness_min); + let max = compute_input_weight(&script_max, &witness_max); + InputWeights { + min, + max, + is_segwit: true, + } + } + InputScriptType::P2wsh => { + let (script_min, witness_min) = get_p2wsh_components(ECDSA_SIG_MIN); + let (script_max, witness_max) = get_p2wsh_components(ECDSA_SIG_MAX); + let min = compute_input_weight(&script_min, &witness_min); + let max = compute_input_weight(&script_max, &witness_max); + InputWeights { + min, + max, + is_segwit: true, + } + } + InputScriptType::P2trLegacy => { + // Legacy p2tr uses script path level 1 by default (user+bitgo) + let (script, witness) = get_p2tr_script_path_components(1); + let w = compute_input_weight(&script, &witness); + InputWeights { + min: w, + max: w, + is_segwit: true, + } + } + InputScriptType::P2trMusig2KeyPath => { + let (script, witness) = get_p2tr_keypath_components(); + let w = compute_input_weight(&script, &witness); + InputWeights { + min: w, + max: w, + is_segwit: true, + } + } + InputScriptType::P2trMusig2ScriptPath => { + let (script, witness) = get_p2tr_script_path_components(1); + let w = compute_input_weight(&script, &witness); + InputWeights { + min: w, + max: w, + is_segwit: true, + } + } + InputScriptType::P2shP2pk => { + let min = compute_input_weight(&get_p2sh_p2pk_components(ECDSA_SIG_MIN), &[]); + let max = compute_input_weight(&get_p2sh_p2pk_components(ECDSA_SIG_MAX), &[]); + InputWeights { + min, + max, + is_segwit: false, + } + } + } +} + +/// Get input weights for a chain code with optional signer/cosigner +fn get_input_weights_for_chain( + chain: u32, + _signer: Option<&str>, + cosigner: Option<&str>, +) -> Result { + let chain_enum = Chain::try_from(chain).map_err(|e| e.to_string())?; + + match chain_enum { + Chain::P2shExternal | Chain::P2shInternal => { + Ok(get_input_weights_for_type(InputScriptType::P2sh)) + } + Chain::P2shP2wshExternal | Chain::P2shP2wshInternal => { + Ok(get_input_weights_for_type(InputScriptType::P2shP2wsh)) + } + Chain::P2wshExternal | Chain::P2wshInternal => { + Ok(get_input_weights_for_type(InputScriptType::P2wsh)) + } + Chain::P2trExternal | Chain::P2trInternal => { + // Legacy p2tr - always script path + // user+bitgo = level 1, user+backup = level 2 + let is_recovery = cosigner == Some("backup"); + let level = if is_recovery { 2 } else { 1 }; + let (script, witness) = get_p2tr_script_path_components(level); + let w = compute_input_weight(&script, &witness); + Ok(InputWeights { + min: w, + max: w, + is_segwit: true, + }) + } + Chain::P2trMusig2External | Chain::P2trMusig2Internal => { + // p2trMusig2 - keypath for user+bitgo, scriptpath for user+backup + let is_recovery = cosigner == Some("backup"); + if is_recovery { + let (script, witness) = get_p2tr_script_path_components(1); + let w = compute_input_weight(&script, &witness); + Ok(InputWeights { + min: w, + max: w, + is_segwit: true, + }) + } else { + let (script, witness) = get_p2tr_keypath_components(); + let w = compute_input_weight(&script, &witness); + Ok(InputWeights { + min: w, + max: w, + is_segwit: true, + }) + } + } + } +} + +/// Parse script type string to InputScriptType enum +fn parse_script_type(script_type: &str) -> Result { + match script_type { + "p2sh" => Ok(InputScriptType::P2sh), + "p2shP2wsh" => Ok(InputScriptType::P2shP2wsh), + "p2wsh" => Ok(InputScriptType::P2wsh), + "p2trLegacy" => Ok(InputScriptType::P2trLegacy), + "p2trMusig2KeyPath" => Ok(InputScriptType::P2trMusig2KeyPath), + "p2trMusig2ScriptPath" => Ok(InputScriptType::P2trMusig2ScriptPath), + "p2shP2pk" => Ok(InputScriptType::P2shP2pk), + _ => Err(format!("Unknown script type: {}", script_type)), + } +} + +// ============================================================================ +// Output weight calculation +// ============================================================================ + +/// Compute output weight from script length +/// Output weight = 4 * (8 bytes value + scriptLength + varint) +fn compute_output_weight(script_length: usize) -> usize { + 4 * (8 + var_slice_size(script_length)) +} + +// ============================================================================ +// WasmDimensions struct +// ============================================================================ + +/// Dimensions for estimating transaction virtual size. +/// +/// Tracks weight internally with min/max bounds to handle ECDSA signature variance. +/// Schnorr signatures have no variance (always 64 bytes). +#[wasm_bindgen] +pub struct WasmDimensions { + input_weight_min: usize, + input_weight_max: usize, + output_weight: usize, + has_segwit: bool, +} + +#[wasm_bindgen] +impl WasmDimensions { + /// Create empty dimensions (zero weight) + pub fn empty() -> WasmDimensions { + WasmDimensions { + input_weight_min: 0, + input_weight_max: 0, + output_weight: 0, + has_segwit: false, + } + } + + /// 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. + pub fn from_psbt(psbt: &BitGoPsbt) -> Result { + let inner_psbt = psbt.psbt.psbt(); + let unsigned_tx = &inner_psbt.unsigned_tx; + + let mut input_weight_min: usize = 0; + let mut input_weight_max: usize = 0; + let mut has_segwit = false; + + // 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 { + Chain::P2shExternal | Chain::P2shInternal => InputScriptType::P2sh, + Chain::P2shP2wshExternal | Chain::P2shP2wshInternal => { + InputScriptType::P2shP2wsh + } + Chain::P2wshExternal | Chain::P2wshInternal => InputScriptType::P2wsh, + Chain::P2trExternal | Chain::P2trInternal => InputScriptType::P2trLegacy, + Chain::P2trMusig2External | Chain::P2trMusig2Internal => { + // 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 + } + } + }; + + get_input_weights_for_type(script_type) + } + 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) + } + }; + + input_weight_min += weights.min; + input_weight_max += weights.max; + has_segwit = has_segwit || weights.is_segwit; + } + + // Process outputs + let mut output_weight: usize = 0; + for output in &unsigned_tx.output { + output_weight += compute_output_weight(output.script_pubkey.len()); + } + + Ok(WasmDimensions { + input_weight_min, + input_weight_max, + output_weight, + has_segwit, + }) + } + + /// Create dimensions for a single input from chain code + /// + /// # Arguments + /// * `chain` - Chain code (0/1=p2sh, 10/11=p2shP2wsh, 20/21=p2wsh, 30/31=p2tr, 40/41=p2trMusig2) + /// * `signer` - Optional signer key ("user", "backup", "bitgo") + /// * `cosigner` - Optional cosigner key ("user", "backup", "bitgo") + pub fn from_input( + chain: u32, + signer: Option, + cosigner: Option, + ) -> Result { + let weights = get_input_weights_for_chain(chain, signer.as_deref(), cosigner.as_deref()) + .map_err(|e| WasmUtxoError::new(&e))?; + + Ok(WasmDimensions { + input_weight_min: weights.min, + input_weight_max: weights.max, + output_weight: 0, + has_segwit: weights.is_segwit, + }) + } + + /// Create dimensions for a single input from script type string + /// + /// # Arguments + /// * `script_type` - One of: "p2sh", "p2shP2wsh", "p2wsh", "p2trLegacy", + /// "p2trMusig2KeyPath", "p2trMusig2ScriptPath", "p2shP2pk" + pub fn from_input_script_type(script_type: &str) -> Result { + let parsed = parse_script_type(script_type).map_err(|e| WasmUtxoError::new(&e))?; + let weights = get_input_weights_for_type(parsed); + + Ok(WasmDimensions { + input_weight_min: weights.min, + input_weight_max: weights.max, + output_weight: 0, + has_segwit: weights.is_segwit, + }) + } + + /// Create dimensions for a single output from script bytes + pub fn from_output_script(script: &[u8]) -> WasmDimensions { + let weight = compute_output_weight(script.len()); + WasmDimensions { + input_weight_min: 0, + input_weight_max: 0, + output_weight: weight, + has_segwit: false, + } + } + + /// Combine with another Dimensions instance + pub fn plus(&self, other: &WasmDimensions) -> WasmDimensions { + WasmDimensions { + input_weight_min: self.input_weight_min + other.input_weight_min, + input_weight_max: self.input_weight_max + other.input_weight_max, + output_weight: self.output_weight + other.output_weight, + has_segwit: self.has_segwit || other.has_segwit, + } + } + + /// Whether any inputs are segwit (affects overhead calculation) + pub fn has_segwit(&self) -> bool { + self.has_segwit + } + + /// Check if this Dimensions has any content (inputs or outputs) + fn has_content(&self) -> bool { + self.input_weight_max > 0 || self.output_weight > 0 + } + + /// Get the overhead weight (transaction structure) + fn get_overhead_weight(&self) -> usize { + if !self.has_content() { + return 0; + } + let overhead_size = if self.has_segwit { + TX_SEGWIT_OVERHEAD_SIZE + } else { + TX_OVERHEAD_SIZE + }; + 4 * overhead_size + } + + /// Get total weight (min or max) + /// + /// # Arguments + /// * `size` - "min" or "max", defaults to "max" + pub fn get_weight(&self, size: Option) -> u32 { + let use_min = size.as_deref() == Some("min"); + let input_weight = if use_min { + self.input_weight_min + } else { + self.input_weight_max + }; + (self.get_overhead_weight() + input_weight + self.output_weight) as u32 + } + + /// Get virtual size (min or max) + /// + /// # Arguments + /// * `size` - "min" or "max", defaults to "max" + pub fn get_vsize(&self, size: Option) -> u32 { + let weight = self.get_weight(size); + weight.div_ceil(4) + } +} diff --git a/packages/wasm-utxo/src/wasm/fixed_script_wallet.rs b/packages/wasm-utxo/src/wasm/fixed_script_wallet/mod.rs similarity index 99% rename from packages/wasm-utxo/src/wasm/fixed_script_wallet.rs rename to packages/wasm-utxo/src/wasm/fixed_script_wallet/mod.rs index 223cea164b2..e7e65658e2b 100644 --- a/packages/wasm-utxo/src/wasm/fixed_script_wallet.rs +++ b/packages/wasm-utxo/src/wasm/fixed_script_wallet/mod.rs @@ -1,3 +1,7 @@ +mod dimensions; + +pub use dimensions::WasmDimensions; + use std::collections::HashMap; use wasm_bindgen::prelude::*; use wasm_bindgen::JsValue; @@ -83,7 +87,7 @@ impl FixedScriptWalletNamespace { } #[wasm_bindgen] pub struct BitGoPsbt { - psbt: crate::fixed_script_wallet::bitgo_psbt::BitGoPsbt, + pub(crate) psbt: crate::fixed_script_wallet::bitgo_psbt::BitGoPsbt, // Store FirstRound states per (input_index, xpub_string) #[wasm_bindgen(skip)] first_rounds: HashMap<(usize, String), musig2::FirstRound>, diff --git a/packages/wasm-utxo/src/wasm/mod.rs b/packages/wasm-utxo/src/wasm/mod.rs index 0f1ba50f51d..87b83f9fbba 100644 --- a/packages/wasm-utxo/src/wasm/mod.rs +++ b/packages/wasm-utxo/src/wasm/mod.rs @@ -19,7 +19,7 @@ pub use bip32::WasmBIP32; pub use dash_transaction::WasmDashTransaction; pub use descriptor::WrapDescriptor; pub use ecpair::WasmECPair; -pub use fixed_script_wallet::FixedScriptWalletNamespace; +pub use fixed_script_wallet::{FixedScriptWalletNamespace, WasmDimensions}; pub use miniscript::WrapMiniscript; pub use psbt::WrapPsbt; pub use replay_protection::WasmReplayProtection; diff --git a/packages/wasm-utxo/src/wasm/transaction.rs b/packages/wasm-utxo/src/wasm/transaction.rs index 2ef7c6c64b6..492fbec1b50 100644 --- a/packages/wasm-utxo/src/wasm/transaction.rs +++ b/packages/wasm-utxo/src/wasm/transaction.rs @@ -42,6 +42,17 @@ impl WasmTransaction { .expect("encoding to vec should never fail"); bytes } + + /// Get the virtual size of the transaction + /// + /// Virtual size is calculated as ceil(weight / 4), where weight accounts + /// for the segwit discount on witness data. + /// + /// # Returns + /// The virtual size in virtual bytes (vbytes) + pub fn get_vsize(&self) -> usize { + self.tx.vsize() + } } /// A Zcash transaction with network-specific fields diff --git a/packages/wasm-utxo/test/dimensions.ts b/packages/wasm-utxo/test/dimensions.ts new file mode 100644 index 00000000000..81b5b41debd --- /dev/null +++ b/packages/wasm-utxo/test/dimensions.ts @@ -0,0 +1,354 @@ +import assert from "node:assert"; +import * as utxolib from "@bitgo/utxo-lib"; +import { Dimensions, fixedScriptWallet } from "../js/index.js"; +import { Transaction } from "../js/transaction.js"; +import { + loadPsbtFixture, + getPsbtBuffer, + type Fixture, + type Output, +} from "./fixedScript/fixtureUtil.js"; +import { getFixtureNetworks } from "./fixedScript/networkSupport.util.js"; +import type { InputScriptType } from "../js/fixedScriptWallet/BitGoPsbt.js"; + +/** + * Map fixture psbtInput type to InputScriptType + */ +function fixtureTypeToInputScriptType(fixtureType: string): InputScriptType | null { + switch (fixtureType) { + case "p2sh": + return "p2sh"; + case "p2shP2wsh": + return "p2shP2wsh"; + case "p2wsh": + return "p2wsh"; + case "p2tr": + return "p2trLegacy"; + case "p2trMusig2": + // Script path spend (2-of-2 Schnorr in tapleaf) + return "p2trMusig2ScriptPath"; + case "taprootKeyPathSpend": + return "p2trMusig2KeyPath"; + case "p2shP2pk": + return "p2shP2pk"; + default: + return null; + } +} + +/** + * Build Dimensions from fixture outputs + */ +function dimensionsFromOutputs(outputs: Output[]): Dimensions { + let dim = Dimensions.empty(); + for (const output of outputs) { + const script = Buffer.from(output.script, "hex"); + dim = dim.plus(Dimensions.fromOutput(script)); + } + return dim; +} + +describe("Dimensions", function () { + describe("empty", function () { + it("should return zero vSize for empty dimensions", function () { + const dim = Dimensions.empty(); + assert.strictEqual(dim.getVSize(), 0); + assert.strictEqual(dim.getVSize("min"), 0); + assert.strictEqual(dim.getWeight(), 0); + assert.strictEqual(dim.getWeight("min"), 0); + assert.strictEqual(dim.hasSegwit, false); + }); + }); + + describe("fromInput", function () { + it("should create dimensions for p2sh input", function () { + const dim = Dimensions.fromInput({ chain: 0 }); + assert.strictEqual(dim.hasSegwit, false); + // p2sh has ECDSA variance + assert.ok(dim.getVSize("min") < dim.getVSize("max")); + }); + + it("should create dimensions for p2shP2wsh input", function () { + const dim = Dimensions.fromInput({ chain: 10 }); + assert.strictEqual(dim.hasSegwit, true); + // p2shP2wsh has ECDSA variance + assert.ok(dim.getVSize("min") < dim.getVSize("max")); + }); + + it("should create dimensions for p2wsh input", function () { + const dim = Dimensions.fromInput({ chain: 20 }); + assert.strictEqual(dim.hasSegwit, true); + // p2wsh has ECDSA variance + assert.ok(dim.getVSize("min") < dim.getVSize("max")); + }); + + it("should create dimensions for p2trLegacy input (user+bitgo)", function () { + const dim = Dimensions.fromInput({ chain: 30 }); + assert.strictEqual(dim.hasSegwit, true); + // Schnorr has no variance + assert.strictEqual(dim.getVSize("min"), dim.getVSize("max")); + }); + + it("should create dimensions for p2trLegacy input (user+backup)", function () { + const dim = Dimensions.fromInput({ + chain: 30, + signPath: { signer: "user", cosigner: "backup" }, + }); + assert.strictEqual(dim.hasSegwit, true); + // Level 2 should be larger than level 1 + const level1 = Dimensions.fromInput({ chain: 30 }); + assert.ok(dim.getVSize() > level1.getVSize()); + }); + + it("should create dimensions for p2trMusig2 keypath (user+bitgo)", function () { + const dim = Dimensions.fromInput({ chain: 40 }); + assert.strictEqual(dim.hasSegwit, true); + // Schnorr has no variance + assert.strictEqual(dim.getVSize("min"), dim.getVSize("max")); + }); + + it("should create dimensions for p2trMusig2 scriptpath (user+backup)", function () { + const dim = Dimensions.fromInput({ + chain: 40, + signPath: { signer: "user", cosigner: "backup" }, + }); + assert.strictEqual(dim.hasSegwit, true); + // Script path should be larger than key path + const keypath = Dimensions.fromInput({ chain: 40 }); + assert.ok(dim.getVSize() > keypath.getVSize()); + }); + + it("should create dimensions for p2shP2pk input", function () { + const dim = Dimensions.fromInput({ scriptType: "p2shP2pk" }); + assert.strictEqual(dim.hasSegwit, false); + // p2shP2pk has ECDSA variance + assert.ok(dim.getVSize("min") < dim.getVSize("max")); + }); + + it("should create same dimensions for scriptType and chain code", function () { + const fromChain = Dimensions.fromInput({ chain: 10 }); + const fromType = Dimensions.fromInput({ scriptType: "p2shP2wsh" }); + assert.strictEqual(fromChain.getWeight("min"), fromType.getWeight("min")); + assert.strictEqual(fromChain.getWeight("max"), fromType.getWeight("max")); + }); + }); + + describe("fromOutput", function () { + it("should create dimensions for p2sh output (23 bytes)", function () { + const script = Buffer.alloc(23); + const dim = Dimensions.fromOutput(script); + // Output weight = 4 * (8 + 1 + 23) = 128 + // Plus overhead (4 * 10 = 40) since there's content + // Total = 168, vSize = 42 + assert.strictEqual(dim.getWeight(), 168); + assert.strictEqual(dim.getVSize(), 42); + }); + + it("should create dimensions for p2wsh output (34 bytes)", function () { + const script = Buffer.alloc(34); + const dim = Dimensions.fromOutput(script); + // Output weight = 4 * (8 + 1 + 34) = 172 + // Plus overhead (4 * 10 = 40) = 212 + assert.strictEqual(dim.getWeight(), 212); + assert.strictEqual(dim.getVSize(), 53); + }); + + it("should create dimensions for p2tr output (34 bytes)", function () { + const script = Buffer.alloc(34); + const dim = Dimensions.fromOutput(script); + // Output weight = 4 * (8 + 1 + 34) = 172 + // Plus overhead (4 * 10 = 40) = 212 + assert.strictEqual(dim.getWeight(), 212); + assert.strictEqual(dim.getVSize(), 53); + }); + + it("should create dimensions from address string", function () { + // p2wpkh address -> 22 byte script + const dim = Dimensions.fromOutput("bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4", "btc"); + // Output weight = 4 * (8 + 1 + 22) = 124 + // Plus overhead (4 * 10 = 40) = 164 + assert.strictEqual(dim.getWeight(), 164); + assert.strictEqual(dim.getVSize(), 41); + }); + + it("should throw when address is provided without network", function () { + assert.throws(() => { + // @ts-expect-error - testing runtime error + Dimensions.fromOutput("bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4"); + }, /network is required/); + }); + }); + + describe("plus", function () { + it("should combine dimensions", function () { + const input = Dimensions.fromInput({ chain: 10 }); + const output = Dimensions.fromOutput(Buffer.alloc(23)); + const combined = input.plus(output); + + // When combining, overhead is only counted once (not doubled) + // The combined weight should be greater than either individual weight + assert.ok(combined.getWeight("min") > input.getWeight("min")); + assert.ok(combined.getWeight("max") > output.getWeight("max")); + assert.strictEqual(combined.hasSegwit, true); + + // Combined should have segwit overhead (44) not non-segwit (40) + // since input is segwit + const empty = Dimensions.empty(); + const combinedViaEmpty = empty.plus(input).plus(output); + assert.strictEqual(combined.getWeight("min"), combinedViaEmpty.getWeight("min")); + }); + + it("should preserve segwit flag from either operand", function () { + const segwit = Dimensions.fromInput({ chain: 20 }); + const nonSegwit = Dimensions.fromInput({ chain: 0 }); + + assert.strictEqual(segwit.plus(nonSegwit).hasSegwit, true); + assert.strictEqual(nonSegwit.plus(segwit).hasSegwit, true); + }); + }); + + describe("integration tests with fixtures", function () { + // Zcash has additional transaction overhead (version group, expiry height, etc.) + // that we don't account for in Dimensions - skip it for now + const networksToTest = getFixtureNetworks().filter((n) => n !== utxolib.networks.zcash); + + networksToTest.forEach((network) => { + const networkName = utxolib.getNetworkName(network); + + describe(`${networkName}`, function () { + let fixture: Fixture; + + before(function () { + fixture = loadPsbtFixture(networkName, "fullsigned"); + }); + + it("actual vSize is within estimated min/max bounds", function () { + if (!fixture.extractedTransaction) { + this.skip(); + return; + } + + // Build dimensions from fixture inputs + let dim = Dimensions.empty(); + + for (const psbtInput of fixture.psbtInputs) { + const scriptType = fixtureTypeToInputScriptType(psbtInput.type); + if (scriptType === null) { + throw new Error(`Unknown input type: ${psbtInput.type}`); + } + dim = dim.plus(Dimensions.fromInput({ scriptType })); + } + + // Add outputs + dim = dim.plus(dimensionsFromOutputs(fixture.outputs)); + + // Get actual vSize from extracted transaction + const txBytes = Buffer.from(fixture.extractedTransaction, "hex"); + const actualVSize = Transaction.fromBytes(txBytes).getVSize(); + + // Get estimated bounds + const minVSize = dim.getVSize("min"); + const maxVSize = dim.getVSize("max"); + + // Actual should be within bounds + assert.ok(actualVSize >= minVSize, `actual ${actualVSize} < min ${minVSize}`); + assert.ok(actualVSize <= maxVSize, `actual ${actualVSize} > max ${maxVSize}`); + }); + }); + }); + }); + + describe("manual construction test", function () { + it("builds correct dimensions for bitcoin fixture", function () { + const fixture = loadPsbtFixture("bitcoin", "fullsigned"); + if (!fixture.extractedTransaction) { + return; + } + + // Build dimensions based on fixture input types: + // 0: p2sh, 1: p2shP2wsh, 2: p2wsh, 3: p2tr (script), + // 4: p2trMusig2 (script path), 5: p2trMusig2 (keypath), 6: p2shP2pk + let dim = Dimensions.empty() + .plus(Dimensions.fromInput({ chain: 0 })) // p2sh + .plus(Dimensions.fromInput({ chain: 11 })) // p2shP2wsh + .plus(Dimensions.fromInput({ chain: 21 })) // p2wsh + .plus(Dimensions.fromInput({ chain: 31 })) // p2tr script path level 1 + .plus( + Dimensions.fromInput({ + chain: 41, + signPath: { signer: "user", cosigner: "backup" }, + }), + ) // p2trMusig2 script path + .plus(Dimensions.fromInput({ chain: 41 })) // p2trMusig2 keypath + .plus(Dimensions.fromInput({ scriptType: "p2shP2pk" })); // replay protection + + // Add outputs + dim = dim.plus(dimensionsFromOutputs(fixture.outputs)); + + // Build dimensions using scriptType + let dimFromTypes = Dimensions.empty() + .plus(Dimensions.fromInput({ scriptType: "p2sh" })) + .plus(Dimensions.fromInput({ scriptType: "p2shP2wsh" })) + .plus(Dimensions.fromInput({ scriptType: "p2wsh" })) + .plus(Dimensions.fromInput({ scriptType: "p2trLegacy" })) + .plus(Dimensions.fromInput({ scriptType: "p2trMusig2ScriptPath" })) + .plus(Dimensions.fromInput({ scriptType: "p2trMusig2KeyPath" })) + .plus(Dimensions.fromInput({ scriptType: "p2shP2pk" })); + + dimFromTypes = dimFromTypes.plus(dimensionsFromOutputs(fixture.outputs)); + + // Both methods should produce same weights + assert.strictEqual(dim.getWeight("min"), dimFromTypes.getWeight("min")); + assert.strictEqual(dim.getWeight("max"), dimFromTypes.getWeight("max")); + + // Get actual vSize + const txBytes = Buffer.from(fixture.extractedTransaction, "hex"); + const actualVSize = Transaction.fromBytes(txBytes).getVSize(); + + // Should be within bounds + assert.ok( + actualVSize >= dim.getVSize("min"), + `actual ${actualVSize} < min ${dim.getVSize("min")}`, + ); + assert.ok( + actualVSize <= dim.getVSize("max"), + `actual ${actualVSize} > max ${dim.getVSize("max")}`, + ); + }); + }); + + describe("fromPsbt", function () { + // Zcash has additional transaction overhead that we don't account for + const networksToTest = getFixtureNetworks().filter((n) => n !== utxolib.networks.zcash); + + networksToTest.forEach((network) => { + const networkName = utxolib.getNetworkName(network); + + describe(`${networkName}`, function () { + it("actual vSize is within fromPsbt estimated bounds", function () { + const fixture = loadPsbtFixture(networkName, "fullsigned"); + if (!fixture.extractedTransaction) { + this.skip(); + return; + } + + // Load PSBT and compute dimensions directly + const psbt = fixedScriptWallet.BitGoPsbt.fromBytes(getPsbtBuffer(fixture), networkName); + const dim = Dimensions.fromPsbt(psbt); + + // Get actual vSize from extracted transaction + const txBytes = Buffer.from(fixture.extractedTransaction, "hex"); + const actualVSize = Transaction.fromBytes(txBytes).getVSize(); + + // Get estimated bounds + const minVSize = dim.getVSize("min"); + const maxVSize = dim.getVSize("max"); + + // Actual should be within bounds + assert.ok(actualVSize >= minVSize, `actual ${actualVSize} < min ${minVSize}`); + assert.ok(actualVSize <= maxVSize, `actual ${actualVSize} > max ${maxVSize}`); + }); + }); + }); + }); +});