diff --git a/modules/unspents/package.json b/modules/unspents/package.json index dbb8cada86..128cd6238d 100644 --- a/modules/unspents/package.json +++ b/modules/unspents/package.json @@ -37,6 +37,7 @@ }, "dependencies": { "@bitgo/utxo-lib": "^11.19.0", + "@bitgo/wasm-utxo": "^1.17.0", "lodash": "~4.17.21", "tcomb": "~3.2.29", "varuint-bitcoin": "^1.0.4" diff --git a/modules/unspents/src/dimensions.ts b/modules/unspents/src/dimensions.ts index 413cf28263..c554fea599 100644 --- a/modules/unspents/src/dimensions.ts +++ b/modules/unspents/src/dimensions.ts @@ -1,5 +1,7 @@ import * as utxolib from '@bitgo/utxo-lib'; import { bitgo } from '@bitgo/utxo-lib'; +import { fixedScriptWallet } from '@bitgo/wasm-utxo'; + const { isChainCode, scriptTypeForChain } = bitgo; type ChainCode = bitgo.ChainCode; @@ -227,20 +229,75 @@ export class Dimensions { }; /** - * @return + * Wasm-utxo input script type names (aliases for utxolib types) + */ + static readonly WasmInputScriptTypes = ['p2trLegacy', 'p2trMusig2ScriptPath', 'p2trMusig2KeyPath'] as const; + + /** + * All supported input script type names (utxolib + wasm-utxo) + */ + static readonly InputScriptTypes = [ + // utxolib names + 'p2sh', + 'p2shP2wsh', + 'p2wsh', + 'p2shP2pk', + 'p2tr', + 'p2trMusig2', + 'taprootKeyPathSpend', + 'taprootScriptPathSpend', + // wasm-utxo names (aliases) + ...Dimensions.WasmInputScriptTypes, + ] as const; + + /** + * Create Dimensions from a script type. + * + * Accepts both utxolib and wasm-utxo naming conventions: + * - utxolib: 'p2sh', 'p2shP2wsh', 'p2wsh', 'p2shP2pk', 'p2tr', 'p2trMusig2', 'taprootKeyPathSpend', 'taprootScriptPathSpend' + * - wasm-utxo: 'p2trLegacy', 'p2trMusig2ScriptPath', 'p2trMusig2KeyPath' + * + * @param scriptType - The script type string (from utxolib or wasm-utxo) + * @param params - Optional parameters + * @param params.scriptPathLevel - For taproot script path spends, specify level 1 or 2. + * - For 'p2tr'/'taprootScriptPathSpend': required (1 or 2) + * - For 'p2trMusig2': undefined = key path, 1 = script path + * - For 'p2trLegacy': defaults to 2 (recovery path) if not specified + * @returns Dimensions for the input + * + * @example + * ```typescript + * // Using utxolib names + * Dimensions.fromScriptType('p2shP2wsh'); + * Dimensions.fromScriptType('taprootKeyPathSpend'); + * + * // Using wasm-utxo names (from parseTransactionWithWalletKeys) + * Dimensions.fromScriptType('p2trMusig2KeyPath'); + * Dimensions.fromScriptType('p2trLegacy', { scriptPathLevel: 1 }); + * ``` */ static fromScriptType( - scriptType: utxolib.bitgo.outputScripts.ScriptType | utxolib.bitgo.ParsedScriptType2Of3 | 'p2pkh', + scriptType: + | utxolib.bitgo.outputScripts.ScriptType + | utxolib.bitgo.ParsedScriptType2Of3 + | 'p2pkh' + // wasm-utxo names (aliases) + | 'p2trLegacy' + | 'p2trMusig2ScriptPath' + | 'p2trMusig2KeyPath', params: { scriptPathLevel?: number; } = {} ): Dimensions { switch (scriptType) { + // Common types (same name in both utxolib and wasm-utxo) case 'p2sh': case 'p2shP2wsh': case 'p2wsh': case 'p2shP2pk': return Dimensions.SingleInput[scriptType]; + + // utxolib taproot names case 'p2tr': case 'taprootScriptPathSpend': switch (params.scriptPathLevel) { @@ -262,6 +319,25 @@ export class Dimensions { } case 'taprootKeyPathSpend': return Dimensions.SingleInput.p2trKeypath; + + // wasm-utxo taproot names (aliases) + case 'p2trLegacy': + // Legacy taproot (non-musig2) script path; default to level 2 (recovery) if not specified + switch (params.scriptPathLevel ?? 2) { + case 1: + return Dimensions.SingleInput.p2trScriptPathLevel1; + case 2: + return Dimensions.SingleInput.p2trScriptPathLevel2; + default: + throw new Error(`unexpected script path level for p2trLegacy: ${params.scriptPathLevel}`); + } + case 'p2trMusig2ScriptPath': + // MuSig2 script path spend (always level 1) + return Dimensions.SingleInput.p2trScriptPathLevel1; + case 'p2trMusig2KeyPath': + // MuSig2 key path spend + return Dimensions.SingleInput.p2trKeypath; + default: throw new Error(`unexpected scriptType ${scriptType}`); } @@ -431,11 +507,42 @@ export class Dimensions { } /** - * Create Dimensions from psbt inputs and outputs - * @param psbt + * Create Dimensions from a PSBT. + * + * Accepts either: + * - A utxolib UtxoPsbt instance + * - A wasm-utxo BitGoPsbt instance + network + * + * @param psbt - Either a UtxoPsbt instance or a wasm-utxo BitGoPsbt instance + * @param network - Required when passing wasm-utxo BitGoPsbt, optional for utxolib UtxoPsbt * @return {Dimensions} + * + * @example + * ```typescript + * // With utxolib PSBT (existing usage) + * const dims = Dimensions.fromPsbt(utxolibPsbt); + * + * // With wasm-utxo PSBT (new usage) + * const dims = Dimensions.fromPsbt(wasmPsbt, network); + * ``` */ - static fromPsbt(psbt: bitgo.UtxoPsbt): Dimensions { + static fromPsbt(psbt: bitgo.UtxoPsbt): Dimensions; + static fromPsbt(wasmPsbt: fixedScriptWallet.BitGoPsbt, network: utxolib.Network): Dimensions; + static fromPsbt(psbt: bitgo.UtxoPsbt | fixedScriptWallet.BitGoPsbt, network?: utxolib.Network): Dimensions { + // Check if it's a wasm-utxo BitGoPsbt + if (psbt instanceof fixedScriptWallet.BitGoPsbt) { + // wasm-utxo BitGoPsbt - serialize and parse with utxolib + if (!network) { + throw new Error('network is required when passing wasm-utxo BitGoPsbt'); + } + const buffer = Buffer.from(psbt.serialize()); + const utxolibPsbt = bitgo.createPsbtFromBuffer(buffer, network); + return Dimensions.fromPsbtInputs(utxolibPsbt.data.inputs).plus( + Dimensions.fromOutputs(utxolibPsbt.getUnsignedTx().outs) + ); + } + + // utxolib UtxoPsbt return Dimensions.fromPsbtInputs(psbt.data.inputs).plus(Dimensions.fromOutputs(psbt.getUnsignedTx().outs)); } diff --git a/modules/unspents/test/dimensions.ts b/modules/unspents/test/dimensions.ts index 120a151d3b..c1d55f8169 100644 --- a/modules/unspents/test/dimensions.ts +++ b/modules/unspents/test/dimensions.ts @@ -211,6 +211,54 @@ describe('Dimensions from unspent types', function () { }); }); +describe('Dimensions fromScriptType with wasm-utxo names', function () { + it('accepts wasm-utxo script type names', function () { + // wasm-utxo taproot names + Dimensions.fromScriptType('p2trMusig2KeyPath').should.eql(Dimensions.SingleInput.p2trKeypath); + Dimensions.fromScriptType('p2trMusig2ScriptPath').should.eql(Dimensions.SingleInput.p2trScriptPathLevel1); + + // p2trLegacy defaults to level 2 (recovery path) + Dimensions.fromScriptType('p2trLegacy').should.eql(Dimensions.SingleInput.p2trScriptPathLevel2); + Dimensions.fromScriptType('p2trLegacy', { scriptPathLevel: 1 }).should.eql( + Dimensions.SingleInput.p2trScriptPathLevel1 + ); + Dimensions.fromScriptType('p2trLegacy', { scriptPathLevel: 2 }).should.eql( + Dimensions.SingleInput.p2trScriptPathLevel2 + ); + }); + + it('accepts utxolib script type names', function () { + // utxolib names should still work + Dimensions.fromScriptType('taprootKeyPathSpend').should.eql(Dimensions.SingleInput.p2trKeypath); + Dimensions.fromScriptType('taprootScriptPathSpend', { scriptPathLevel: 1 }).should.eql( + Dimensions.SingleInput.p2trScriptPathLevel1 + ); + Dimensions.fromScriptType('p2trMusig2').should.eql(Dimensions.SingleInput.p2trKeypath); + Dimensions.fromScriptType('p2trMusig2', { scriptPathLevel: 1 }).should.eql( + Dimensions.SingleInput.p2trScriptPathLevel1 + ); + }); + + it('provides WasmInputScriptTypes constant', function () { + Dimensions.WasmInputScriptTypes.should.containEql('p2trLegacy'); + Dimensions.WasmInputScriptTypes.should.containEql('p2trMusig2ScriptPath'); + Dimensions.WasmInputScriptTypes.should.containEql('p2trMusig2KeyPath'); + }); + + it('provides InputScriptTypes constant with all types', function () { + // utxolib types + Dimensions.InputScriptTypes.should.containEql('p2sh'); + Dimensions.InputScriptTypes.should.containEql('p2shP2wsh'); + Dimensions.InputScriptTypes.should.containEql('p2wsh'); + Dimensions.InputScriptTypes.should.containEql('taprootKeyPathSpend'); + Dimensions.InputScriptTypes.should.containEql('taprootScriptPathSpend'); + // wasm-utxo types + Dimensions.InputScriptTypes.should.containEql('p2trLegacy'); + Dimensions.InputScriptTypes.should.containEql('p2trMusig2ScriptPath'); + Dimensions.InputScriptTypes.should.containEql('p2trMusig2KeyPath'); + }); +}); + describe('Dimensions estimates', function () { it('calculates vsizes', function () { function dim(nP2shInputs: number, nP2shP2wshInputs: number, nP2wshInputs: number, nOutputs: number): Dimensions { diff --git a/modules/unspents/test/signedTx/txCombinations.ts b/modules/unspents/test/signedTx/txCombinations.ts index 06374bced2..5ea600a99d 100644 --- a/modules/unspents/test/signedTx/txCombinations.ts +++ b/modules/unspents/test/signedTx/txCombinations.ts @@ -149,6 +149,34 @@ describe(`Dimensions for PSBT combinations`, function () { should.throws(() => Dimensions.fromPsbt(psbt)); }); + it(`accepts wasm-utxo BitGoPsbt`, function () { + const psbt = constructPsbt(rootWalletKeys, ['p2shP2wsh'], ['p2wpkh'], 'unsigned'); + + // Get dimensions from UtxoPsbt directly + const dimsFromUtxolib = Dimensions.fromPsbt(psbt); + + // Create a wasm-utxo BitGoPsbt from the same serialized bytes + const { fixedScriptWallet } = require('@bitgo/wasm-utxo'); + const wasmPsbt = fixedScriptWallet.BitGoPsbt.fromBytes(psbt.toBuffer(), 'bitcoin'); + + // Get dimensions from wasm-utxo BitGoPsbt + const dimsFromWasm = Dimensions.fromPsbt(wasmPsbt, utxolib.networks.bitcoin); + + // Should be equal + dimsFromWasm.should.eql(dimsFromUtxolib); + }); + + it(`requires network when passing wasm-utxo BitGoPsbt`, function () { + const psbt = constructPsbt(rootWalletKeys, ['p2shP2wsh'], ['p2wpkh'], 'unsigned'); + + // Create a wasm-utxo BitGoPsbt + const { fixedScriptWallet } = require('@bitgo/wasm-utxo'); + const wasmPsbt = fixedScriptWallet.BitGoPsbt.fromBytes(psbt.toBuffer(), 'bitcoin'); + + // Should throw when network is not provided + should.throws(() => (Dimensions.fromPsbt as any)(wasmPsbt), /network is required/); + }); + runCombinations(params, (inputTypeCombo: InputScriptType[], outputTypeCombo: TestUnspentType[]) => { const expectedInputDims = Dimensions.sum(...inputTypeCombo.map(getInputDimensionsForUnspentType)); const expectedOutputDims = Dimensions.sum(...outputTypeCombo.map(getOutputDimensionsForUnspentType)); diff --git a/yarn.lock b/yarn.lock index 4857ceff08..2461df146d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -973,6 +973,11 @@ resolved "https://registry.npmjs.org/@bitgo/wasm-utxo/-/wasm-utxo-1.17.0.tgz#ea5c6ebd1a9a50965aa987485db2e875433e7275" integrity sha512-1/ZO6MRRvHzKggwNGc0pCs781Xi8tTwsAbC2M+3qwdDSYXRAdG2IQ+Hmx84MTJ7xKhWX5m8rtezJL47dGwT1YQ== +"@bitgo/wasm-utxo@^1.17.0": + version "1.19.0" + resolved "https://registry.npmjs.org/@bitgo/wasm-utxo/-/wasm-utxo-1.19.0.tgz#c44db54da8bfa748f3a7a24f769519ff56783236" + integrity sha512-M6NtRfJrWoJP68IF1bm2eNMzUdIGnIQjIDwcIMXaqJCuWXPQot8KbKHVJPe3EpdB9g4a/J5hd6JIhZRF8m7Dhw== + "@brandonblack/musig@^0.0.1-alpha.0": version "0.0.1-alpha.1" resolved "https://registry.npmjs.org/@brandonblack/musig/-/musig-0.0.1-alpha.1.tgz"