From f4575be852355fbe23eed0f90aa8ac1c7436c4fd Mon Sep 17 00:00:00 2001 From: Otto Allmendinger Date: Wed, 14 Jan 2026 15:44:43 +0100 Subject: [PATCH] feat(wasm-utxo): add utxolibCompat option for consistent size estimates Adds compatibility mode to make input size estimates match @bitgo/unspents. This allows wasm-utxo to produce the same transaction size estimates as utxo-lib when needed, while still supporting accurate min/max bounds. The key change is using 72-byte signatures for ECDSA inputs in compat mode instead of 73-byte for the max calculation, which matches @bitgo/unspents behavior. The implementation also replicates specific encoding choices used in utxo-lib. Issue: BTC-2908 Co-authored-by: llm-git --- package-lock.json | 148 ++++- .../js/fixedScriptWallet/Dimensions.ts | 24 +- packages/wasm-utxo/package.json | 1 + .../wasm/fixed_script_wallet/dimensions.rs | 90 ++- .../test/dimensionsUnspentsCompat.ts | 618 ++++++++++++++++++ 5 files changed, 852 insertions(+), 29 deletions(-) create mode 100644 packages/wasm-utxo/test/dimensionsUnspentsCompat.ts diff --git a/package-lock.json b/package-lock.json index 88d599b..21274bd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -122,15 +122,17 @@ "license": "ISC" }, "node_modules/@bitgo/secp256k1": { - "version": "1.8.0", - "resolved": "https://registry.npmjs.org/@bitgo/secp256k1/-/secp256k1-1.8.0.tgz", - "integrity": "sha512-sdVLB9qtrgL9Yi0vmCQIbeGZcTXhMwoadHEWZd1gka9Z0n3G4sdwxR+P2d2vFbgNbAJFt/9k8b1WOX9RFZ5e4w==", + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/@bitgo/secp256k1/-/secp256k1-1.9.0.tgz", + "integrity": "sha512-1iwJ4BnG8WovUcWkjIR2n+9IgfOz07jKThvpWxuq6HIUsjoUDkMndrpcIrs+4c+CmPUWb74u4rVCo13EdW3zPw==", "dev": true, "license": "MIT", "dependencies": { "@brandonblack/musig": "^0.0.1-alpha.0", "@noble/secp256k1": "1.6.3", "bip32": "^3.0.1", + "bitcoinjs-message": "npm:@bitgo-forks/bitcoinjs-message@1.0.0-master.3", + "bs58check": "^2.1.2", "create-hash": "^1.2.0", "create-hmac": "^1.1.7", "ecpair": "npm:@bitgo/ecpair@2.1.0-rc.0" @@ -140,6 +142,67 @@ "npm": ">=3.10.10" } }, + "node_modules/@bitgo/unspents": { + "version": "0.50.14", + "resolved": "https://registry.npmjs.org/@bitgo/unspents/-/unspents-0.50.14.tgz", + "integrity": "sha512-8UDZZuI8nAv9D8PU8E0uPMkHb/Sunb0YAwFasaohVnZE5emOHwfvTPnaJRUA3CXDjjKyYhOAxB+YwNNIZtv9bQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@bitgo/utxo-lib": "^11.19.1", + "lodash": "~4.17.21", + "tcomb": "~3.2.29", + "varuint-bitcoin": "^1.0.4" + } + }, + "node_modules/@bitgo/unspents/node_modules/@bitgo/utxo-lib": { + "version": "11.19.1", + "resolved": "https://registry.npmjs.org/@bitgo/utxo-lib/-/utxo-lib-11.19.1.tgz", + "integrity": "sha512-w6gYqVOMkGHo2U00MsLuiZ4DzKrQ/69emgdypbjZtKGvn331ty4/S4ZEubBAGG6TcXFhzusWOJfg1ra2EP/T/A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@bitgo/blake2b": "^3.2.4", + "@bitgo/secp256k1": "^1.9.0", + "@brandonblack/musig": "^0.0.1-alpha.0", + "bech32": "^2.0.0", + "bip174": "npm:@bitgo-forks/bip174@3.1.0-master.4", + "bitcoin-ops": "^1.3.0", + "bitcoinjs-lib": "npm:@bitgo-forks/bitcoinjs-lib@7.1.0-master.11", + "bs58check": "^2.1.2", + "cashaddress": "^1.1.0", + "fastpriorityqueue": "^0.7.1", + "typeforce": "^1.11.3", + "varuint-bitcoin": "^1.1.2" + }, + "engines": { + "node": ">=20 <23", + "npm": ">=3.10.10" + } + }, + "node_modules/@bitgo/unspents/node_modules/bitcoinjs-lib": { + "name": "@bitgo-forks/bitcoinjs-lib", + "version": "7.1.0-master.11", + "resolved": "https://registry.npmjs.org/@bitgo-forks/bitcoinjs-lib/-/bitcoinjs-lib-7.1.0-master.11.tgz", + "integrity": "sha512-Yyh67I26iI7FGqPBY7rxqHZ9FM9JuouAsViQocrr7URhRpuZEWVsM/oMTNbMnRw2cPFj4jWKhRDLadgrUk2HEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "bech32": "^2.0.0", + "bip174": "npm:@bitgo-forks/bip174@3.1.0-master.4", + "bs58check": "^2.1.2", + "create-hash": "^1.1.0", + "fastpriorityqueue": "^0.7.1", + "json5": "^2.2.3", + "ripemd160": "^2.0.2", + "typeforce": "^1.11.3", + "varuint-bitcoin": "^1.1.2", + "wif": "^2.0.1" + }, + "engines": { + "node": ">=8.0.0" + } + }, "node_modules/@bitgo/utxo-lib": { "version": "10.1.0", "resolved": "https://registry.npmjs.org/@bitgo/utxo-lib/-/utxo-lib-10.1.0.tgz", @@ -5643,6 +5706,32 @@ "node": ">=8.0.0" } }, + "node_modules/bitcoinjs-message": { + "name": "@bitgo-forks/bitcoinjs-message", + "version": "1.0.0-master.3", + "resolved": "https://registry.npmjs.org/@bitgo-forks/bitcoinjs-message/-/bitcoinjs-message-1.0.0-master.3.tgz", + "integrity": "sha512-mWMXFSb9pTcbxcvU4cQGkickuhPDnpadHs6eUK6F07pJZ42O4eA3j0anwfTsfpqs8UpSzM8UtrUEG4ao5+/yZg==", + "dev": true, + "license": "MIT", + "dependencies": { + "bech32": "^1.1.3", + "bs58check": "^2.1.2", + "buffer-equals": "^1.0.3", + "create-hash": "^1.1.2", + "secp256k1": "5.0.1", + "varuint-bitcoin": "^1.0.1" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/bitcoinjs-message/node_modules/bech32": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/bech32/-/bech32-1.1.4.tgz", + "integrity": "sha512-s0IrSOzLlbvX7yp4WBfPITzpAU8sqQcpsmwXDiKwrG4r491vwCO/XpejasRNl0piBMe/DvP4Tz0mIS/X1DPJBQ==", + "dev": true, + "license": "MIT" + }, "node_modules/bl": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", @@ -5859,6 +5948,16 @@ "safe-buffer": "^5.1.2" } }, + "node_modules/buffer-equals": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/buffer-equals/-/buffer-equals-1.0.4.tgz", + "integrity": "sha512-99MsCq0j5+RhubVEtKQgKaD6EM+UP3xJgIvQqwJ3SOLDUekzxMX1ylXBng+Wa2sh7mGT0W6RUly8ojjr1Tt6nA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/buffer-from": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", @@ -12832,6 +12931,13 @@ "tslib": "^2.0.3" } }, + "node_modules/node-addon-api": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-5.1.0.tgz", + "integrity": "sha512-eh0GgfEkpnoWDq+VY8OyvYhFEzBk6jIYbRKdIlyTiAXIVJ8PyBaKb0rp7oDtoddbdoHWhq8wwr+XZ81F1rpNdA==", + "dev": true, + "license": "MIT" + }, "node_modules/node-emoji": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/node-emoji/-/node-emoji-2.2.0.tgz", @@ -12882,6 +12988,18 @@ "node": "^18.17.0 || >=20.5.0" } }, + "node_modules/node-gyp-build": { + "version": "4.8.4", + "resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.4.tgz", + "integrity": "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==", + "dev": true, + "license": "MIT", + "bin": { + "node-gyp-build": "bin.js", + "node-gyp-build-optional": "optional.js", + "node-gyp-build-test": "build-test.js" + } + }, "node_modules/node-gyp/node_modules/@npmcli/agent": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/@npmcli/agent/-/agent-3.0.0.tgz", @@ -17764,6 +17882,22 @@ "url": "https://opencollective.com/webpack" } }, + "node_modules/secp256k1": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/secp256k1/-/secp256k1-5.0.1.tgz", + "integrity": "sha512-lDFs9AAIaWP9UCdtWrotXWWF9t8PWgQDcxqgAnpM9rMqxb3Oaq2J0thzPVSxBwdJgyQtkU/sYtFtbM1RSt/iYA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "elliptic": "^6.5.7", + "node-addon-api": "^5.0.0", + "node-gyp-build": "^4.2.0" + }, + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/select-hose": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/select-hose/-/select-hose-2.0.0.tgz", @@ -19616,6 +19750,13 @@ "node": ">=8" } }, + "node_modules/tcomb": { + "version": "3.2.29", + "resolved": "https://registry.npmjs.org/tcomb/-/tcomb-3.2.29.tgz", + "integrity": "sha512-di2Hd1DB2Zfw6StGv861JoAF5h/uQVu/QJp2g8KVbtfKnoHdBQl5M32YWq6mnSYBQ1vFFrns5B1haWJL7rKaOQ==", + "dev": true, + "license": "MIT" + }, "node_modules/temp-dir": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/temp-dir/-/temp-dir-1.0.0.tgz", @@ -21385,6 +21526,7 @@ "version": "0.0.2", "license": "MIT", "devDependencies": { + "@bitgo/unspents": "^0.50.13", "@bitgo/utxo-lib": "^10.1.0", "@eslint/js": "^9.17.0", "@types/mocha": "^10.0.7", diff --git a/packages/wasm-utxo/js/fixedScriptWallet/Dimensions.ts b/packages/wasm-utxo/js/fixedScriptWallet/Dimensions.ts index d382247..f797192 100644 --- a/packages/wasm-utxo/js/fixedScriptWallet/Dimensions.ts +++ b/packages/wasm-utxo/js/fixedScriptWallet/Dimensions.ts @@ -6,6 +6,17 @@ import { toOutputScriptWithCoin } from "../address.js"; type FromInputParams = { chain: number; signPath?: SignPath } | { scriptType: InputScriptType }; +/** + * Options for input dimension calculation + */ +export type FromInputOptions = { + /** + * When true, use @bitgo/unspents-compatible signature sizes (72 bytes) + * for the "max" calculation instead of true maximum (73 bytes). + */ + utxolibCompat?: boolean; +}; + /** * Dimensions class for estimating transaction virtual size. * @@ -39,13 +50,20 @@ export class Dimensions { * Create dimensions for a single input * * @param params - Either `{ chain, signPath? }` or `{ scriptType }` + * @param options - Optional settings like `{ utxolibCompat: true }` for @bitgo/unspents-compatible sizing */ - static fromInput(params: FromInputParams): Dimensions { + static fromInput(params: FromInputParams, options?: FromInputOptions): Dimensions { + const compat = options?.utxolibCompat; if ("scriptType" in params) { - return new Dimensions(WasmDimensions.from_input_script_type(params.scriptType)); + return new Dimensions(WasmDimensions.from_input_script_type(params.scriptType, compat)); } return new Dimensions( - WasmDimensions.from_input(params.chain, params.signPath?.signer, params.signPath?.cosigner), + WasmDimensions.from_input( + params.chain, + params.signPath?.signer, + params.signPath?.cosigner, + compat, + ), ); } diff --git a/packages/wasm-utxo/package.json b/packages/wasm-utxo/package.json index c2e4db4..26a63aa 100644 --- a/packages/wasm-utxo/package.json +++ b/packages/wasm-utxo/package.json @@ -52,6 +52,7 @@ "lint:fix": "eslint . --fix" }, "devDependencies": { + "@bitgo/unspents": "^0.50.13", "@bitgo/utxo-lib": "^10.1.0", "@eslint/js": "^9.17.0", "@types/mocha": "^10.0.7", 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 8ae5984..9851312 100644 --- a/packages/wasm-utxo/src/wasm/fixed_script_wallet/dimensions.rs +++ b/packages/wasm-utxo/src/wasm/fixed_script_wallet/dimensions.rs @@ -22,6 +22,8 @@ use super::BitGoPsbt; // ECDSA signature sizes (DER encoding variance) const ECDSA_SIG_MIN: usize = 71; const ECDSA_SIG_MAX: usize = 73; +// @bitgo/unspents uses a fixed 72-byte signature size +const ECDSA_SIG_COMPAT: usize = 72; // Schnorr signature (fixed size, no sighash byte in witness) const SCHNORR_SIG: usize = 64; @@ -87,12 +89,19 @@ struct InputWeights { } /// Get p2sh 2-of-3 multisig input components -fn get_p2sh_components(sig_size: usize) -> Vec { +/// +/// # Arguments +/// * `sig_size` - Signature size (71, 72, or 73 bytes) +/// * `compat` - When true, use OP_PUSHDATA2 encoding for redeemScript (matches @bitgo/unspents) +fn get_p2sh_components(sig_size: usize, compat: bool) -> Vec { + // @bitgo/unspents uses OP_PUSHDATA2 (3 bytes) for redeemScript push, + // while minimal encoding uses OP_PUSHDATA1 (2 bytes) for 105-byte scripts + let redeem_script_push_overhead = if compat { 3 } else { 2 }; 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 + OP_PUSH_SIZE + sig_size, // sig 1 + OP_PUSH_SIZE + sig_size, // sig 2 + redeem_script_push_overhead + P2MS_PUB_SCRIPT_SIZE, // redeemScript with push ] } @@ -143,19 +152,36 @@ fn get_p2tr_keypath_components() -> (Vec, Vec) { } /// Get p2sh-p2pk input components (single signature, used for replay protection) -fn get_p2sh_p2pk_components(sig_size: usize) -> Vec { +/// +/// # Arguments +/// * `sig_size` - Signature size (71, 72, or 73 bytes) +/// * `compat` - When true, use OP_PUSHDATA1 encoding for redeemScript (matches @bitgo/unspents) +fn get_p2sh_p2pk_components(sig_size: usize, compat: bool) -> Vec { + // @bitgo/unspents uses OP_PUSHDATA1 (2 bytes) for redeemScript push, + // while minimal encoding uses direct push (1 byte) for 35-byte scripts + let redeem_script_push_overhead = if compat { 2 } else { 1 }; vec![ - OP_PUSH_SIZE + sig_size, // signature - OP_PUSH_SIZE + P2PK_PUB_SCRIPT_SIZE, // redeemScript (pubkey + OP_CHECKSIG) + OP_PUSH_SIZE + sig_size, // signature + redeem_script_push_overhead + 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 { +/// +/// # Arguments +/// * `script_type` - The input script type +/// * `compat` - When true, use 72-byte signatures for max (matches @bitgo/unspents) +fn get_input_weights_for_type(script_type: InputScriptType, compat: bool) -> InputWeights { + let sig_max = if compat { + ECDSA_SIG_COMPAT + } else { + ECDSA_SIG_MAX + }; + 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), &[]); + let min = compute_input_weight(&get_p2sh_components(ECDSA_SIG_MIN, false), &[]); + let max = compute_input_weight(&get_p2sh_components(sig_max, compat), &[]); InputWeights { min, max, @@ -164,7 +190,7 @@ fn get_input_weights_for_type(script_type: InputScriptType) -> InputWeights { } 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 (script_max, witness_max) = get_p2sh_p2wsh_components(sig_max); let min = compute_input_weight(&script_min, &witness_min); let max = compute_input_weight(&script_max, &witness_max); InputWeights { @@ -175,7 +201,7 @@ fn get_input_weights_for_type(script_type: InputScriptType) -> InputWeights { } 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 (script_max, witness_max) = get_p2wsh_components(sig_max); let min = compute_input_weight(&script_min, &witness_min); let max = compute_input_weight(&script_max, &witness_max); InputWeights { @@ -186,6 +212,7 @@ fn get_input_weights_for_type(script_type: InputScriptType) -> InputWeights { } InputScriptType::P2trLegacy => { // Legacy p2tr uses script path level 1 by default (user+bitgo) + // Schnorr signatures have no variance, compat flag has no effect let (script, witness) = get_p2tr_script_path_components(1); let w = compute_input_weight(&script, &witness); InputWeights { @@ -195,6 +222,7 @@ fn get_input_weights_for_type(script_type: InputScriptType) -> InputWeights { } } InputScriptType::P2trMusig2KeyPath => { + // Schnorr signatures have no variance, compat flag has no effect let (script, witness) = get_p2tr_keypath_components(); let w = compute_input_weight(&script, &witness); InputWeights { @@ -204,6 +232,7 @@ fn get_input_weights_for_type(script_type: InputScriptType) -> InputWeights { } } InputScriptType::P2trMusig2ScriptPath => { + // Schnorr signatures have no variance, compat flag has no effect let (script, witness) = get_p2tr_script_path_components(1); let w = compute_input_weight(&script, &witness); InputWeights { @@ -213,8 +242,8 @@ fn get_input_weights_for_type(script_type: InputScriptType) -> InputWeights { } } 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), &[]); + let min = compute_input_weight(&get_p2sh_p2pk_components(ECDSA_SIG_MIN, false), &[]); + let max = compute_input_weight(&get_p2sh_p2pk_components(sig_max, compat), &[]); InputWeights { min, max, @@ -229,13 +258,17 @@ fn get_input_weights_for_chain( chain: u32, _signer: Option<&str>, cosigner: Option<&str>, + compat: bool, ) -> Result { let chain_enum = Chain::try_from(chain).map_err(|e| e.to_string())?; match chain_enum.script_type { - OutputScriptType::P2sh => Ok(get_input_weights_for_type(InputScriptType::P2sh)), - OutputScriptType::P2shP2wsh => Ok(get_input_weights_for_type(InputScriptType::P2shP2wsh)), - OutputScriptType::P2wsh => Ok(get_input_weights_for_type(InputScriptType::P2wsh)), + OutputScriptType::P2sh => Ok(get_input_weights_for_type(InputScriptType::P2sh, compat)), + OutputScriptType::P2shP2wsh => Ok(get_input_weights_for_type( + InputScriptType::P2shP2wsh, + compat, + )), + OutputScriptType::P2wsh => Ok(get_input_weights_for_type(InputScriptType::P2wsh, compat)), OutputScriptType::P2trLegacy => { // Legacy p2tr - always script path // user+bitgo = level 1, user+backup = level 2 @@ -369,13 +402,13 @@ impl WasmDimensions { } }; - get_input_weights_for_type(script_type) + 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) + get_input_weights_for_type(InputScriptType::P2shP2pk, false) } }; @@ -404,13 +437,20 @@ impl WasmDimensions { /// * `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") + /// * `compat` - When true, use 72-byte signatures for max (matches @bitgo/unspents) pub fn from_input( chain: u32, signer: Option, cosigner: Option, + compat: Option, ) -> Result { - let weights = get_input_weights_for_chain(chain, signer.as_deref(), cosigner.as_deref()) - .map_err(|e| WasmUtxoError::new(&e))?; + let weights = get_input_weights_for_chain( + chain, + signer.as_deref(), + cosigner.as_deref(), + compat.unwrap_or(false), + ) + .map_err(|e| WasmUtxoError::new(&e))?; Ok(WasmDimensions { input_weight_min: weights.min, @@ -425,9 +465,13 @@ impl WasmDimensions { /// # Arguments /// * `script_type` - One of: "p2sh", "p2shP2wsh", "p2wsh", "p2trLegacy", /// "p2trMusig2KeyPath", "p2trMusig2ScriptPath", "p2shP2pk" - pub fn from_input_script_type(script_type: &str) -> Result { + /// * `compat` - When true, use 72-byte signatures for max (matches @bitgo/unspents) + pub fn from_input_script_type( + script_type: &str, + compat: Option, + ) -> Result { let parsed = parse_script_type(script_type).map_err(|e| WasmUtxoError::new(&e))?; - let weights = get_input_weights_for_type(parsed); + let weights = get_input_weights_for_type(parsed, compat.unwrap_or(false)); Ok(WasmDimensions { input_weight_min: weights.min, diff --git a/packages/wasm-utxo/test/dimensionsUnspentsCompat.ts b/packages/wasm-utxo/test/dimensionsUnspentsCompat.ts new file mode 100644 index 0000000..9464a53 --- /dev/null +++ b/packages/wasm-utxo/test/dimensionsUnspentsCompat.ts @@ -0,0 +1,618 @@ +/** + * Tests comparing wasm-utxo Dimensions against @bitgo/unspents Dimensions. + * + * ## Key Differences + * + * ### ECDSA Signature Size + * + * The main difference between the two implementations is how they handle ECDSA signature variance: + * + * - **@bitgo/unspents**: Uses a fixed 72-byte signature size for all ECDSA inputs. + * This is the most common size, but actual signatures can range from 71-73 bytes. + * + * - **wasm-utxo**: Tracks min/max weight bounds using: + * - min: 71-byte signatures (low-R, low-S) + * - max: 73-byte signatures (high-R, high-S) + * + * This means for ECDSA-based script types (p2sh, p2shP2wsh, p2wsh, p2shP2pk), + * @bitgo/unspents returns a single value that falls between wasm-utxo's min and max. + * + * ### Discrepancy for p2sh + * + * For p2sh, the vSize range is larger than expected due to varint encoding: + * - When scriptSig crosses 253 bytes, the varint length increases from 1 to 3 bytes + * - With 71-byte sigs: scriptSig = 252 bytes (varint = 1 byte) + * - With 73-byte sigs: scriptSig = 256 bytes (varint = 3 bytes) + * + * Observed values: + * - wasm-utxo min (71-byte sigs): 293 vSize + * - @bitgo/unspents (72-byte sigs, 3-byte varint): 298 vSize + * - wasm-utxo max (73-byte sigs): 299 vSize + * + * Note: @bitgo/unspents uses OP_PUSHDATA2 (3 bytes) for the redeemScript push regardless + * of actual size, while wasm-utxo uses the minimal encoding. This is why @bitgo/unspents + * is close to wasm-utxo's max rather than in the middle. + * + * For segwit inputs (p2shP2wsh, p2wsh), the @bitgo/unspents value equals wasm-utxo's + * max because witness data uses ceiling division differently than non-witness data. + * + * For Schnorr-based script types (p2tr, p2trMusig2), signatures are always 64 bytes, + * so both implementations produce identical results. + */ + +import assert from "node:assert"; +import { Dimensions as WasmDimensions } from "../js/fixedScriptWallet/Dimensions.js"; +import { Dimensions as UnspentsDimensions, VirtualSizes } from "@bitgo/unspents"; + +describe("Dimensions: wasm-utxo vs @bitgo/unspents compatibility", function () { + describe("input vSize comparison", function () { + /** + * p2sh DISCREPANCY DOCUMENTATION: + * + * @bitgo/unspents uses a fixed 72-byte ECDSA signature size. + * wasm-utxo tracks min (71) / max (73) bounds. + * + * For p2sh with 2-of-3 multisig (2 signatures): + * - Signatures vary: 71, 72, or 73 bytes + * - Additional variance from varint encoding (scriptSig crosses 253-byte boundary) + * + * Observed values: + * - wasm-utxo min (71-byte sigs, 1-byte varint): 293 vSize + * - @bitgo/unspents (72-byte sigs, 3-byte varint): 298 vSize + * - wasm-utxo max (73-byte sigs, 3-byte varint): 299 vSize + * + * @bitgo/unspents uses OP_PUSHDATA2 (3 bytes) for the redeemScript push regardless + * of actual scriptSig size, which is why it's close to wasm-utxo's max. + */ + it("p2sh: @bitgo/unspents value is between wasm-utxo min and max", function () { + const wasmDim = WasmDimensions.fromInput({ scriptType: "p2sh" }); + const unspentsDim = UnspentsDimensions.SingleInput.p2sh; + + const wasmMin = wasmDim.getInputVSize("min"); + const wasmMax = wasmDim.getInputVSize("max"); + const unspentsSize = unspentsDim.getInputsVSize(); + + // Verify that wasm-utxo has variance (min < max) + assert.ok( + wasmMin < wasmMax, + `p2sh should have ECDSA variance: min=${wasmMin}, max=${wasmMax}`, + ); + + // @bitgo/unspents (72-byte sig) should be between wasm-utxo min (71-byte) and max (73-byte) + assert.ok( + unspentsSize >= wasmMin && unspentsSize <= wasmMax, + `@bitgo/unspents ${unspentsSize} should be between wasm-utxo min=${wasmMin} and max=${wasmMax}`, + ); + + // Document the specific values + assert.strictEqual(wasmMin, 293, "p2sh wasm-utxo min vSize"); + assert.strictEqual(wasmMax, 299, "p2sh wasm-utxo max vSize"); + // Note: @bitgo/unspents uses 72-byte sigs but also uses 3-byte varint for scriptSig + // (OP_PUSHDATA2 comment in inputWeights.ts), resulting in a higher vSize + assert.strictEqual(unspentsSize, 298, "@bitgo/unspents p2sh vSize"); + }); + + /** + * p2shP2wsh DISCREPANCY: + * + * For segwit inputs, @bitgo/unspents equals wasm-utxo's max. + * This is due to ceiling division in vSize calculation affecting segwit witness differently. + */ + it("p2shP2wsh: @bitgo/unspents equals wasm-utxo max", function () { + const wasmDim = WasmDimensions.fromInput({ scriptType: "p2shP2wsh" }); + const unspentsDim = UnspentsDimensions.SingleInput.p2shP2wsh; + + const wasmMin = wasmDim.getInputVSize("min"); + const wasmMax = wasmDim.getInputVSize("max"); + const unspentsSize = unspentsDim.getInputsVSize(); + + // Verify that wasm-utxo has variance (min < max) + assert.ok( + wasmMin < wasmMax, + `p2shP2wsh should have ECDSA variance: min=${wasmMin}, max=${wasmMax}`, + ); + + // Document: @bitgo/unspents equals wasm-utxo max for segwit inputs + assert.strictEqual( + unspentsSize, + wasmMax, + `p2shP2wsh: @bitgo/unspents ${unspentsSize} equals wasm-utxo max ${wasmMax}`, + ); + }); + + /** + * p2wsh DISCREPANCY: + * + * Same as p2shP2wsh - @bitgo/unspents equals wasm-utxo's max for segwit. + */ + it("p2wsh: @bitgo/unspents equals wasm-utxo max", function () { + const wasmDim = WasmDimensions.fromInput({ scriptType: "p2wsh" }); + const unspentsDim = UnspentsDimensions.SingleInput.p2wsh; + + const wasmMin = wasmDim.getInputVSize("min"); + const wasmMax = wasmDim.getInputVSize("max"); + const unspentsSize = unspentsDim.getInputsVSize(); + + // Verify that wasm-utxo has variance (min < max) + assert.ok( + wasmMin < wasmMax, + `p2wsh should have ECDSA variance: min=${wasmMin}, max=${wasmMax}`, + ); + + // Document: @bitgo/unspents equals wasm-utxo max for segwit inputs + assert.strictEqual( + unspentsSize, + wasmMax, + `p2wsh: @bitgo/unspents ${unspentsSize} equals wasm-utxo max ${wasmMax}`, + ); + }); + + /** + * p2shP2pk DISCREPANCY: + * + * Same pattern - @bitgo/unspents equals wasm-utxo's max. + */ + it("p2shP2pk: @bitgo/unspents equals wasm-utxo max", function () { + const wasmDim = WasmDimensions.fromInput({ scriptType: "p2shP2pk" }); + const unspentsDim = UnspentsDimensions.SingleInput.p2shP2pk; + + const wasmMin = wasmDim.getInputVSize("min"); + const wasmMax = wasmDim.getInputVSize("max"); + const unspentsSize = unspentsDim.getInputsVSize(); + + // Verify that wasm-utxo has variance (min < max) + assert.ok( + wasmMin < wasmMax, + `p2shP2pk should have ECDSA variance: min=${wasmMin}, max=${wasmMax}`, + ); + + // Document: @bitgo/unspents equals wasm-utxo max + assert.strictEqual( + unspentsSize, + wasmMax, + `p2shP2pk: @bitgo/unspents ${unspentsSize} equals wasm-utxo max ${wasmMax}`, + ); + }); + + /** + * Schnorr-based inputs have fixed 64-byte signatures, so there's no variance. + * Both implementations should produce identical results. + */ + it("p2tr keypath: both implementations match exactly (Schnorr, no variance)", function () { + const wasmDim = WasmDimensions.fromInput({ scriptType: "p2trMusig2KeyPath" }); + const unspentsDim = UnspentsDimensions.SingleInput.p2trKeypath; + + const wasmMin = wasmDim.getInputVSize("min"); + const wasmMax = wasmDim.getInputVSize("max"); + const unspentsSize = unspentsDim.getInputsVSize(); + + // Schnorr has no variance + assert.strictEqual(wasmMin, wasmMax, "Schnorr inputs should have no variance"); + + // Both should match exactly + assert.strictEqual(wasmMin, unspentsSize, "p2tr keypath vSize should match"); + }); + + it("p2tr script path level 1: both implementations match exactly (Schnorr, no variance)", function () { + const wasmDim = WasmDimensions.fromInput({ scriptType: "p2trLegacy" }); + const unspentsDim = UnspentsDimensions.SingleInput.p2trScriptPathLevel1; + + const wasmMin = wasmDim.getInputVSize("min"); + const wasmMax = wasmDim.getInputVSize("max"); + const unspentsSize = unspentsDim.getInputsVSize(); + + // Schnorr has no variance + assert.strictEqual(wasmMin, wasmMax, "Schnorr inputs should have no variance"); + + // Both should match exactly + assert.strictEqual(wasmMin, unspentsSize, "p2tr script path level 1 vSize should match"); + }); + + it("p2tr script path level 2: both implementations match exactly (Schnorr, no variance)", function () { + const wasmDim = WasmDimensions.fromInput({ + chain: 30, + signPath: { signer: "user", cosigner: "backup" }, + }); + const unspentsDim = UnspentsDimensions.SingleInput.p2trScriptPathLevel2; + + const wasmMin = wasmDim.getInputVSize("min"); + const wasmMax = wasmDim.getInputVSize("max"); + const unspentsSize = unspentsDim.getInputsVSize(); + + // Schnorr has no variance + assert.strictEqual(wasmMin, wasmMax, "Schnorr inputs should have no variance"); + + // Both should match exactly + assert.strictEqual(wasmMin, unspentsSize, "p2tr script path level 2 vSize should match"); + }); + }); + + describe("output vSize comparison", function () { + it("p2sh output: both implementations match exactly", function () { + const wasmDim = WasmDimensions.fromOutput({ scriptType: "p2sh" }); + const unspentsSize = VirtualSizes.txP2shOutputSize; + + assert.strictEqual(wasmDim.getOutputVSize(), unspentsSize, "p2sh output vSize should match"); + }); + + it("p2wsh output: both implementations match exactly", function () { + const wasmDim = WasmDimensions.fromOutput({ scriptType: "p2wsh" }); + const unspentsSize = VirtualSizes.txP2wshOutputSize; + + assert.strictEqual(wasmDim.getOutputVSize(), unspentsSize, "p2wsh output vSize should match"); + }); + + it("p2tr output: both implementations match exactly", function () { + const wasmDim = WasmDimensions.fromOutput({ scriptType: "p2trLegacy" }); + const unspentsSize = VirtualSizes.txP2trOutputSize; + + assert.strictEqual(wasmDim.getOutputVSize(), unspentsSize, "p2tr output vSize should match"); + }); + }); + + describe("overhead vSize comparison", function () { + it("non-segwit overhead: both implementations match", function () { + // Non-segwit overhead is 10 bytes + assert.strictEqual(VirtualSizes.txOverheadSize, 10); + + // wasm-utxo computes overhead as part of total weight + const wasmDim = WasmDimensions.fromInput({ scriptType: "p2sh" }); + const totalVSize = wasmDim.getVSize("max"); + const inputVSize = wasmDim.getInputVSize("max"); + const wasmOverhead = totalVSize - inputVSize; + + assert.strictEqual(wasmOverhead, 10, "non-segwit overhead should be 10"); + }); + + it("segwit overhead: both implementations match", function () { + // Segwit overhead is 11 vSize + assert.strictEqual(VirtualSizes.txSegOverheadVSize, 11); + + // wasm-utxo computes overhead as part of total weight + const wasmDim = WasmDimensions.fromInput({ scriptType: "p2wsh" }); + const totalVSize = wasmDim.getVSize("max"); + const inputVSize = wasmDim.getInputVSize("max"); + const wasmOverhead = totalVSize - inputVSize; + + assert.strictEqual(wasmOverhead, 11, "segwit overhead should be 11"); + }); + }); + + describe("combined transaction vSize comparison", function () { + it("simple 1-input 1-output p2wsh transaction", function () { + // wasm-utxo + const wasmDim = WasmDimensions.fromInput({ scriptType: "p2wsh" }).plus( + WasmDimensions.fromOutput({ scriptType: "p2wsh" }), + ); + + // @bitgo/unspents + const unspentsDim = UnspentsDimensions.SingleInput.p2wsh.plus( + UnspentsDimensions.SingleOutput.p2wsh, + ); + + const unspentsVSize = unspentsDim.getVSize(); + const wasmMin = wasmDim.getVSize("min"); + const wasmMax = wasmDim.getVSize("max"); + + // @bitgo/unspents should be between or equal to wasm-utxo bounds + assert.ok( + wasmMin <= unspentsVSize && unspentsVSize <= wasmMax, + `@bitgo/unspents ${unspentsVSize} should be between wasm-utxo min=${wasmMin} and max=${wasmMax}`, + ); + }); + + it("simple 1-input 1-output p2tr transaction (exact match)", function () { + // wasm-utxo + const wasmDim = WasmDimensions.fromInput({ scriptType: "p2trMusig2KeyPath" }).plus( + WasmDimensions.fromOutput({ scriptType: "p2trMusig2" }), + ); + + // @bitgo/unspents + const unspentsDim = UnspentsDimensions.SingleInput.p2trKeypath.plus( + UnspentsDimensions.SingleOutput.p2tr, + ); + + // Should match exactly since Schnorr has no variance + assert.strictEqual(wasmDim.getVSize("min"), unspentsDim.getVSize()); + assert.strictEqual(wasmDim.getVSize("max"), unspentsDim.getVSize()); + }); + + it("mixed input transaction", function () { + // Transaction with p2sh, p2wsh, and p2tr inputs + const wasmDim = WasmDimensions.fromInput({ scriptType: "p2sh" }) + .plus(WasmDimensions.fromInput({ scriptType: "p2wsh" })) + .plus(WasmDimensions.fromInput({ scriptType: "p2trMusig2KeyPath" })) + .plus(WasmDimensions.fromOutput({ scriptType: "p2wsh" })); + + const unspentsDim = UnspentsDimensions.SingleInput.p2sh + .plus(UnspentsDimensions.SingleInput.p2wsh) + .plus(UnspentsDimensions.SingleInput.p2trKeypath) + .plus(UnspentsDimensions.SingleOutput.p2wsh); + + // @bitgo/unspents should fall between wasm-utxo min and max + const unspentsVSize = unspentsDim.getVSize(); + const wasmMin = wasmDim.getVSize("min"); + const wasmMax = wasmDim.getVSize("max"); + + assert.ok( + unspentsVSize >= wasmMin && unspentsVSize <= wasmMax, + `@bitgo/unspents ${unspentsVSize} should be between wasm-utxo min=${wasmMin} and max=${wasmMax}`, + ); + }); + }); + + describe("input weight comparison (raw values)", function () { + /** + * Document the raw input weights for reference. + * These tests verify the underlying weight calculations. + */ + it("documents p2sh input weight calculation", function () { + const wasmDim = WasmDimensions.fromInput({ scriptType: "p2sh" }); + + // wasm-utxo computes weight as: 4 * base_size (for non-segwit) + // For p2sh 2-of-3 multisig: + // scriptSig = OP_0 + sig1 + sig2 + redeemScript + // = 1 + (1+sig) + (1+sig) + (2+105) + // + // With 71-byte sigs: 1 + 72 + 72 + 107 = 252 bytes + // With 73-byte sigs: 1 + 74 + 74 + 107 = 256 bytes + // + // weight = 4 * (40 + varint + scriptSig) + // min: 4 * (40 + 2 + 252) = 4 * 294 = 1176 (but actual is 1172) + // max: 4 * (40 + 2 + 256) = 4 * 298 = 1192 (but actual is 1188) + + // Log actual values for documentation + const minWeight = wasmDim.getInputWeight("min"); + const maxWeight = wasmDim.getInputWeight("max"); + + // Verify ECDSA variance exists + assert.ok( + minWeight < maxWeight, + `p2sh should have ECDSA variance: ${minWeight} < ${maxWeight}`, + ); + + // Document actual values + // With 2 signatures and 2 byte variance each (71 vs 73), total variance = 2 * 2 * 4 = 16 weight units + // But actual variance is 24 due to varint length change when scriptSig crosses 253 bytes + assert.strictEqual( + maxWeight - minWeight, + 24, + "p2sh weight variance (includes varint boundary)", + ); + }); + + it("documents p2wsh input weight calculation", function () { + const wasmDim = WasmDimensions.fromInput({ scriptType: "p2wsh" }); + + // p2wsh has empty scriptSig, witness contains: OP_0 + sig1 + sig2 + witnessScript + // Weight formula: 3 * base + (base + witness) + // where base = 40 + 1 (empty scriptSig) + + const minWeight = wasmDim.getInputWeight("min"); + const maxWeight = wasmDim.getInputWeight("max"); + + // Verify ECDSA variance exists + assert.ok( + minWeight < maxWeight, + `p2wsh should have ECDSA variance: ${minWeight} < ${maxWeight}`, + ); + + // Document weight variance + assert.strictEqual(maxWeight - minWeight, 4, "p2wsh weight variance should be 4"); + }); + + it("documents p2tr keypath input weight calculation", function () { + const wasmDim = WasmDimensions.fromInput({ scriptType: "p2trMusig2KeyPath" }); + + // p2tr keypath has empty scriptSig, witness contains single 64-byte Schnorr signature + // base_size = 40 + 1 = 41 + // witness = 1 (count) + 1 (length) + 64 (sig) = 66 + // weight = 3*41 + (41 + 66) = 123 + 107 = 230 + + assert.strictEqual(wasmDim.getInputWeight("min"), 230, "p2tr keypath weight"); + assert.strictEqual(wasmDim.getInputWeight("max"), 230, "p2tr keypath weight (no variance)"); + + // vSize = ceil(230 / 4) = 58 + assert.strictEqual(wasmDim.getInputVSize("min"), 58, "p2tr keypath vSize"); + }); + }); + + describe("utxolibCompat option", function () { + /** + * When utxolibCompat: true is passed, the "max" values should match @bitgo/unspents exactly. + * This is achieved by using 72-byte signatures instead of 73-byte for the max calculation. + */ + it("p2sh with utxolibCompat: max matches @bitgo/unspents exactly", function () { + const wasmDim = WasmDimensions.fromInput({ scriptType: "p2sh" }, { utxolibCompat: true }); + const unspentsDim = UnspentsDimensions.SingleInput.p2sh; + + const wasmMax = wasmDim.getInputVSize("max"); + const unspentsSize = unspentsDim.getInputsVSize(); + + // With utxolibCompat, max should match @bitgo/unspents exactly + assert.strictEqual( + wasmMax, + unspentsSize, + `p2sh with utxolibCompat: max ${wasmMax} should equal @bitgo/unspents ${unspentsSize}`, + ); + }); + + it("p2shP2wsh with utxolibCompat: max matches @bitgo/unspents exactly", function () { + const wasmDim = WasmDimensions.fromInput( + { scriptType: "p2shP2wsh" }, + { utxolibCompat: true }, + ); + const unspentsDim = UnspentsDimensions.SingleInput.p2shP2wsh; + + const wasmMax = wasmDim.getInputVSize("max"); + const unspentsSize = unspentsDim.getInputsVSize(); + + assert.strictEqual( + wasmMax, + unspentsSize, + `p2shP2wsh with utxolibCompat: max ${wasmMax} should equal @bitgo/unspents ${unspentsSize}`, + ); + }); + + it("p2wsh with utxolibCompat: max matches @bitgo/unspents exactly", function () { + const wasmDim = WasmDimensions.fromInput({ scriptType: "p2wsh" }, { utxolibCompat: true }); + const unspentsDim = UnspentsDimensions.SingleInput.p2wsh; + + const wasmMax = wasmDim.getInputVSize("max"); + const unspentsSize = unspentsDim.getInputsVSize(); + + assert.strictEqual( + wasmMax, + unspentsSize, + `p2wsh with utxolibCompat: max ${wasmMax} should equal @bitgo/unspents ${unspentsSize}`, + ); + }); + + it("p2shP2pk with utxolibCompat: max matches @bitgo/unspents exactly", function () { + const wasmDim = WasmDimensions.fromInput({ scriptType: "p2shP2pk" }, { utxolibCompat: true }); + const unspentsDim = UnspentsDimensions.SingleInput.p2shP2pk; + + const wasmMax = wasmDim.getInputVSize("max"); + const unspentsSize = unspentsDim.getInputsVSize(); + + assert.strictEqual( + wasmMax, + unspentsSize, + `p2shP2pk with utxolibCompat: max ${wasmMax} should equal @bitgo/unspents ${unspentsSize}`, + ); + }); + + it("Schnorr inputs: utxolibCompat has no effect (already matches)", function () { + // Schnorr signatures are always 64 bytes, so utxolibCompat has no effect + const wasmDimDefault = WasmDimensions.fromInput({ scriptType: "p2trMusig2KeyPath" }); + const wasmDimCompat = WasmDimensions.fromInput( + { scriptType: "p2trMusig2KeyPath" }, + { utxolibCompat: true }, + ); + const unspentsDim = UnspentsDimensions.SingleInput.p2trKeypath; + + assert.strictEqual(wasmDimDefault.getInputVSize("max"), wasmDimCompat.getInputVSize("max")); + assert.strictEqual(wasmDimCompat.getInputVSize("max"), unspentsDim.getInputsVSize()); + }); + + it("utxolibCompat with chain code parameter", function () { + // Test that utxolibCompat works with chain-based input specification + const wasmDim = WasmDimensions.fromInput({ chain: 10 }, { utxolibCompat: true }); // p2shP2wsh + const unspentsDim = UnspentsDimensions.SingleInput.p2shP2wsh; + + assert.strictEqual( + wasmDim.getInputVSize("max"), + unspentsDim.getInputsVSize(), + "chain-based p2shP2wsh with utxolibCompat should match @bitgo/unspents", + ); + }); + + it("all ECDSA input types: utxolibCompat max matches @bitgo/unspents", function () { + const inputTypes: Array<{ + name: string; + wasmScriptType: Parameters[0]; + unspentsDim: typeof UnspentsDimensions.SingleInput.p2sh; + }> = [ + { + name: "p2sh", + wasmScriptType: { scriptType: "p2sh" }, + unspentsDim: UnspentsDimensions.SingleInput.p2sh, + }, + { + name: "p2shP2wsh", + wasmScriptType: { scriptType: "p2shP2wsh" }, + unspentsDim: UnspentsDimensions.SingleInput.p2shP2wsh, + }, + { + name: "p2wsh", + wasmScriptType: { scriptType: "p2wsh" }, + unspentsDim: UnspentsDimensions.SingleInput.p2wsh, + }, + { + name: "p2shP2pk", + wasmScriptType: { scriptType: "p2shP2pk" }, + unspentsDim: UnspentsDimensions.SingleInput.p2shP2pk, + }, + ]; + + for (const { name, wasmScriptType, unspentsDim } of inputTypes) { + const wasmDim = WasmDimensions.fromInput(wasmScriptType, { utxolibCompat: true }); + const wasmMax = wasmDim.getInputVSize("max"); + const unspentsSize = unspentsDim.getInputsVSize(); + + assert.strictEqual( + wasmMax, + unspentsSize, + `${name} with utxolibCompat: max ${wasmMax} should equal @bitgo/unspents ${unspentsSize}`, + ); + } + }); + }); + + describe("summary: wasm-utxo bounds contain @bitgo/unspents values", function () { + /** + * This test verifies the key property: wasm-utxo's [min, max] range + * always contains @bitgo/unspents' single estimate. + * + * This is important for fee estimation: + * - Use wasm-utxo min for optimistic estimates + * - Use wasm-utxo max for conservative estimates + * - @bitgo/unspents gives a reasonable middle-ground + */ + it("all input types: @bitgo/unspents is within wasm-utxo bounds", function () { + const inputTypes: Array<{ + name: string; + wasmScriptType: Parameters[0]; + unspentsDim: typeof UnspentsDimensions.SingleInput.p2sh; + }> = [ + { + name: "p2sh", + wasmScriptType: { scriptType: "p2sh" }, + unspentsDim: UnspentsDimensions.SingleInput.p2sh, + }, + { + name: "p2shP2wsh", + wasmScriptType: { scriptType: "p2shP2wsh" }, + unspentsDim: UnspentsDimensions.SingleInput.p2shP2wsh, + }, + { + name: "p2wsh", + wasmScriptType: { scriptType: "p2wsh" }, + unspentsDim: UnspentsDimensions.SingleInput.p2wsh, + }, + { + name: "p2shP2pk", + wasmScriptType: { scriptType: "p2shP2pk" }, + unspentsDim: UnspentsDimensions.SingleInput.p2shP2pk, + }, + { + name: "p2trKeypath", + wasmScriptType: { scriptType: "p2trMusig2KeyPath" }, + unspentsDim: UnspentsDimensions.SingleInput.p2trKeypath, + }, + { + name: "p2trScriptPathL1", + wasmScriptType: { scriptType: "p2trLegacy" }, + unspentsDim: UnspentsDimensions.SingleInput.p2trScriptPathLevel1, + }, + { + name: "p2trScriptPathL2", + wasmScriptType: { chain: 30, signPath: { signer: "user", cosigner: "backup" } }, + unspentsDim: UnspentsDimensions.SingleInput.p2trScriptPathLevel2, + }, + ]; + + for (const { name, wasmScriptType, unspentsDim } of inputTypes) { + const wasmDim = WasmDimensions.fromInput(wasmScriptType); + const wasmMin = wasmDim.getInputVSize("min"); + const wasmMax = wasmDim.getInputVSize("max"); + const unspentsSize = unspentsDim.getInputsVSize(); + + assert.ok( + wasmMin <= unspentsSize && unspentsSize <= wasmMax, + `${name}: @bitgo/unspents ${unspentsSize} should be within wasm-utxo bounds [${wasmMin}, ${wasmMax}]`, + ); + } + }); + }); +});