From 4b1ecb2e1b7bdac6d023036803941ede31a08c0c Mon Sep 17 00:00:00 2001 From: Otto Allmendinger Date: Mon, 2 Feb 2026 15:35:59 +0100 Subject: [PATCH] feat(abstract-utxo): move to `wasm-utxo` ScriptType and ChainCode Replace utxolib.bitgo usage with equivalent functionality from wasm-utxo, providing proper types and compatibility functions for the ScriptType2Of3 and ChainCode operations. Issue: BTC-2668 Co-authored-by: llm-git --- modules/abstract-utxo/src/abstractUtxoCoin.ts | 26 ++++++---- .../abstract-utxo/src/address/fixedScript.ts | 49 ++++++++++++++++++- modules/abstract-utxo/src/address/index.ts | 6 +++ .../src/recovery/backupKeyRecovery.ts | 28 ++++++----- modules/abstract-utxo/src/recovery/psbt.ts | 10 ++-- modules/abstract-utxo/src/unspent.ts | 4 +- modules/abstract-utxo/test/unit/address.ts | 11 +++-- .../test/unit/prebuildAndSign.ts | 17 +++++-- .../test/unit/recovery/backupKeyRecovery.ts | 24 +++++++-- .../abstract-utxo/test/unit/util/unspents.ts | 35 ++++--------- 10 files changed, 141 insertions(+), 69 deletions(-) diff --git a/modules/abstract-utxo/src/abstractUtxoCoin.ts b/modules/abstract-utxo/src/abstractUtxoCoin.ts index 1b08585bc5..5a2a514b31 100644 --- a/modules/abstract-utxo/src/abstractUtxoCoin.ts +++ b/modules/abstract-utxo/src/abstractUtxoCoin.ts @@ -84,7 +84,13 @@ import { UtxoCoinName, UtxoCoinNameMainnet, } from './names'; -import { assertFixedScriptWalletAddress } from './address/fixedScript'; +import { + assertFixedScriptWalletAddress, + ScriptType2Of3, + scriptTypes2Of3, + UtxolibScriptType, + toUtxolibScriptType, +} from './address/fixedScript'; import { isSdkBackend, ParsedTransaction, SdkBackend } from './transaction/types'; import { decodePsbtWith, encodeTransaction, stringToBufferTryFormats } from './transaction/decode'; import { fetchKeychains, toBip32Triple, UtxoKeychain } from './keychains'; @@ -95,8 +101,6 @@ import { isUtxoWalletData, UtxoWallet } from './wallet'; import { isDescriptorWalletData } from './descriptor/descriptorWallet'; import type { Unspent } from './unspent'; -import ScriptType2Of3 = utxolib.bitgo.outputScripts.ScriptType2Of3; - export type TxFormat = // This is a legacy transaction format based around the bitcoinjs-lib serialization of unsigned transactions // does not include prevOut data and is a bit painful to work with @@ -141,8 +145,6 @@ type UtxoCustomSigningFunction = { }): Promise; }; -const { isChainCode, scriptTypeForChain, outputScripts } = bitgo; - /** * Convert ValidationError to TxIntentMismatchRecipientError with structured data * @@ -425,7 +427,7 @@ export abstract class AbstractUtxoCoin /** @deprecated */ static get validAddressTypes(): ScriptType2Of3[] { - return [...outputScripts.scriptTypes2Of3]; + return [...scriptTypes2Of3]; } /** @@ -533,8 +535,10 @@ export abstract class AbstractUtxoCoin * Determine an address' type based on its witness and redeem script presence * @param addressDetails */ - static inferAddressType(addressDetails: { chain: number }): ScriptType2Of3 | null { - return isChainCode(addressDetails.chain) ? scriptTypeForChain(addressDetails.chain) : null; + static inferAddressType(addressDetails: { chain: number }): UtxolibScriptType | null { + return fixedScriptWallet.ChainCode.is(addressDetails.chain) + ? toUtxolibScriptType(fixedScriptWallet.ChainCode.scriptType(addressDetails.chain)) + : null; } createTransactionFromHex( @@ -716,7 +720,7 @@ export abstract class AbstractUtxoCoin * @returns true iff coin supports spending from unspentType */ supportsAddressType(addressType: ScriptType2Of3): boolean { - return utxolib.bitgo.outputScripts.isSupportedScriptType(this.network, addressType); + return fixedScriptWallet.supportsScriptType(this.name, addressType); } /** inherited doc */ @@ -729,7 +733,9 @@ export abstract class AbstractUtxoCoin * @return true iff coin supports spending from chain */ supportsAddressChain(chain: number): boolean { - return isChainCode(chain) && this.supportsAddressType(utxolib.bitgo.scriptTypeForChain(chain)); + return ( + fixedScriptWallet.ChainCode.is(chain) && this.supportsAddressType(fixedScriptWallet.ChainCode.scriptType(chain)) + ); } keyIdsForSigning(): number[] { diff --git a/modules/abstract-utxo/src/address/fixedScript.ts b/modules/abstract-utxo/src/address/fixedScript.ts index 56c7462f01..e2cf307a4d 100644 --- a/modules/abstract-utxo/src/address/fixedScript.ts +++ b/modules/abstract-utxo/src/address/fixedScript.ts @@ -16,7 +16,54 @@ import { fixedScriptWallet } from '@bitgo/wasm-utxo'; import { UtxoCoinName } from '../names'; -type ScriptType2Of3 = fixedScriptWallet.OutputScriptType; +/** + * Script type for 2-of-3 multisig outputs. + * This is the wasm-utxo OutputScriptType which uses 'p2trLegacy' for taproot. + */ +export type ScriptType2Of3 = fixedScriptWallet.OutputScriptType; + +/** + * utxolib script type format - uses 'p2tr' instead of 'p2trLegacy'. + * This is the format expected by utxolib functions. + */ +export type UtxolibScriptType = 'p2sh' | 'p2shP2wsh' | 'p2wsh' | 'p2tr' | 'p2trMusig2'; + +/** + * All 2-of-3 multisig script types. + * Uses wasm-utxo naming ('p2trLegacy' for taproot). + */ +export const scriptTypes2Of3: readonly ScriptType2Of3[] = ['p2sh', 'p2shP2wsh', 'p2wsh', 'p2trLegacy', 'p2trMusig2']; + +/** + * All 2-of-3 multisig script types in utxolib format. + * Uses utxolib naming ('p2tr' for taproot). + */ +export const utxolibScriptTypes2Of3: readonly UtxolibScriptType[] = [ + 'p2sh', + 'p2shP2wsh', + 'p2wsh', + 'p2tr', + 'p2trMusig2', +]; + +/** + * Convert ScriptType2Of3 to utxolib-compatible format. + * ScriptType2Of3 uses 'p2trLegacy' while utxolib uses 'p2tr'. + */ +export function toUtxolibScriptType(scriptType: ScriptType2Of3): UtxolibScriptType { + return scriptType === 'p2trLegacy' ? 'p2tr' : scriptType; +} + +/** + * Check if a script type requires witness data. + * Witness data is required for segwit and taproot script types. + */ +export function hasWitnessData(scriptType: ScriptType2Of3): boolean { + return ( + scriptType === 'p2shP2wsh' || scriptType === 'p2wsh' || scriptType === 'p2trLegacy' || scriptType === 'p2trMusig2' + ); +} + type ChainCode = fixedScriptWallet.ChainCode; export interface FixedScriptAddressCoinSpecific { diff --git a/modules/abstract-utxo/src/address/index.ts b/modules/abstract-utxo/src/address/index.ts index b4466837b6..c43f6c6715 100644 --- a/modules/abstract-utxo/src/address/index.ts +++ b/modules/abstract-utxo/src/address/index.ts @@ -3,4 +3,10 @@ export { generateAddressWithChainAndIndex, assertFixedScriptWalletAddress, FixedScriptAddressCoinSpecific, + ScriptType2Of3, + UtxolibScriptType, + scriptTypes2Of3, + utxolibScriptTypes2Of3, + toUtxolibScriptType, + hasWitnessData, } from './fixedScript'; diff --git a/modules/abstract-utxo/src/recovery/backupKeyRecovery.ts b/modules/abstract-utxo/src/recovery/backupKeyRecovery.ts index fc4a783b9d..aca8a49081 100644 --- a/modules/abstract-utxo/src/recovery/backupKeyRecovery.ts +++ b/modules/abstract-utxo/src/recovery/backupKeyRecovery.ts @@ -15,7 +15,7 @@ import { fixedScriptWallet } from '@bitgo/wasm-utxo'; import { AbstractUtxoCoin } from '../abstractUtxoCoin'; import { signAndVerifyPsbt } from '../transaction/fixedScript/signTransaction'; -import { generateAddressWithChainAndIndex } from '../address'; +import { generateAddressWithChainAndIndex, ScriptType2Of3, scriptTypes2Of3, hasWitnessData } from '../address'; import { encodeTransaction } from '../transaction/decode'; import { getReplayProtectionPubkeys } from '../transaction/fixedScript/replayProtection'; import { isTestnetCoin, UtxoCoinName } from '../names'; @@ -26,15 +26,12 @@ import { MempoolApi } from './mempoolApi'; import { CoingeckoApi } from './coingeckoApi'; import { createBackupKeyRecoveryPsbt, getRecoveryAmount, PsbtBackend, toPsbtToUtxolibPsbt } from './psbt'; -type ScriptType2Of3 = utxolib.bitgo.outputScripts.ScriptType2Of3; -type ChainCode = utxolib.bitgo.ChainCode; +type ChainCode = fixedScriptWallet.ChainCode; type RootWalletKeys = utxolib.bitgo.RootWalletKeys; type WalletUnspentJSON = WalletUnspent & { valueString: string; }; -const { getInternalChainCode, scriptTypeForChain, outputScripts, getExternalChainCode } = utxolib.bitgo; - // V1 only deals with BTC. 50 sat/vbyte is very arbitrary. export const DEFAULT_RECOVERY_FEERATE_SAT_VBYTE_V1 = 50; @@ -137,9 +134,8 @@ async function queryBlockchainUnspentsPath( walletKeys: RootWalletKeys, chain: ChainCode ): Promise[]> { - const scriptType = scriptTypeForChain(chain); - const fetchPrevTx = - !utxolib.bitgo.outputScripts.hasWitnessData(scriptType) && getMainnet(coin.network) !== networks.zcash; + const scriptType = fixedScriptWallet.ChainCode.scriptType(chain); + const fetchPrevTx = !hasWitnessData(scriptType) && getMainnet(coin.network) !== networks.zcash; const recoveryProvider = params.recoveryProvider ?? forCoin(coin.getChain(), params.apiKey); const MAX_SEQUENTIAL_ADDRESSES_WITHOUT_TXS = params.scan || 20; let numSequentialAddressesWithoutTxs = 0; @@ -315,15 +311,25 @@ export async function backupKeyRecovery( const unspents: WalletUnspent[] = ( await Promise.all( - outputScripts.scriptTypes2Of3 + scriptTypes2Of3 .filter( (addressType) => coin.supportsAddressType(addressType) && !params.ignoreAddressTypes?.includes(addressType) ) .reduce( (queries, addressType) => [ ...queries, - queryBlockchainUnspentsPath(coin, params, walletKeys, getExternalChainCode(addressType)), - queryBlockchainUnspentsPath(coin, params, walletKeys, getInternalChainCode(addressType)), + queryBlockchainUnspentsPath( + coin, + params, + walletKeys, + fixedScriptWallet.ChainCode.value(addressType, 'external') + ), + queryBlockchainUnspentsPath( + coin, + params, + walletKeys, + fixedScriptWallet.ChainCode.value(addressType, 'internal') + ), ], [] as Promise[]>[] ) diff --git a/modules/abstract-utxo/src/recovery/psbt.ts b/modules/abstract-utxo/src/recovery/psbt.ts index 7069e792b2..5e9836422d 100644 --- a/modules/abstract-utxo/src/recovery/psbt.ts +++ b/modules/abstract-utxo/src/recovery/psbt.ts @@ -6,10 +6,7 @@ import { getNetworkFromCoinName, UtxoCoinName } from '../names'; import type { WalletUnspent } from '../unspent'; type RootWalletKeys = utxolib.bitgo.RootWalletKeys; - -const { chainCodesP2tr, chainCodesP2trMusig2 } = utxolib.bitgo; - -type ChainCode = utxolib.bitgo.ChainCode; +type ChainCode = fixedScriptWallet.ChainCode; /** * Backend to use for PSBT creation. @@ -22,9 +19,8 @@ export type PsbtBackend = 'wasm-utxo' | 'utxolib'; * Check if a chain code is for a taproot script type */ export function isTaprootChain(chain: ChainCode): boolean { - return ( - (chainCodesP2tr as readonly number[]).includes(chain) || (chainCodesP2trMusig2 as readonly number[]).includes(chain) - ); + const scriptType = fixedScriptWallet.ChainCode.scriptType(chain); + return scriptType === 'p2trLegacy' || scriptType === 'p2trMusig2'; } /** diff --git a/modules/abstract-utxo/src/unspent.ts b/modules/abstract-utxo/src/unspent.ts index 2fdc43ba89..5757c2f8ab 100644 --- a/modules/abstract-utxo/src/unspent.ts +++ b/modules/abstract-utxo/src/unspent.ts @@ -1,4 +1,4 @@ -import * as utxolib from '@bitgo/utxo-lib'; +import { fixedScriptWallet } from '@bitgo/wasm-utxo'; /** * Unspent transaction output (UTXO) type definition @@ -35,7 +35,7 @@ export interface Unspent { * - index: number (index for wallet derivation) */ export interface WalletUnspent extends Unspent { - chain: utxolib.bitgo.ChainCode; + chain: fixedScriptWallet.ChainCode; index: number; } diff --git a/modules/abstract-utxo/test/unit/address.ts b/modules/abstract-utxo/test/unit/address.ts index 5a4ee10950..b2a3a97e78 100644 --- a/modules/abstract-utxo/test/unit/address.ts +++ b/modules/abstract-utxo/test/unit/address.ts @@ -4,7 +4,12 @@ import * as assert from 'assert'; import * as utxolib from '@bitgo/utxo-lib'; const { chainCodes } = utxolib.bitgo; -import { AbstractUtxoCoin, GenerateFixedScriptAddressOptions, generateAddress } from '../../src'; +import { + AbstractUtxoCoin, + GenerateFixedScriptAddressOptions, + generateAddress, + utxolibScriptTypes2Of3, +} from '../../src'; import { utxoCoins, keychains as keychainsBip32, getFixture, shouldEqualJSON } from './util'; @@ -42,9 +47,7 @@ function run(coin: AbstractUtxoCoin) { describe(`UTXO Addresses ${coin.getChain()}`, function () { it('address support', function () { - const supportedAddressTypes = utxolib.bitgo.outputScripts.scriptTypes2Of3.filter((t) => - coin.supportsAddressType(t) - ); + const supportedAddressTypes = utxolibScriptTypes2Of3.filter((t) => coin.supportsAddressType(t)); switch (coin.getChain()) { case 'btc': case 'tbtc': diff --git a/modules/abstract-utxo/test/unit/prebuildAndSign.ts b/modules/abstract-utxo/test/unit/prebuildAndSign.ts index e41f8f0005..e84aa68faf 100644 --- a/modules/abstract-utxo/test/unit/prebuildAndSign.ts +++ b/modules/abstract-utxo/test/unit/prebuildAndSign.ts @@ -1,11 +1,18 @@ import * as assert from 'assert'; import * as utxolib from '@bitgo/utxo-lib'; +import { fixedScriptWallet } from '@bitgo/wasm-utxo'; import nock = require('nock'); import { common, HalfSignedUtxoTransaction, Wallet } from '@bitgo/sdk-core'; import { getSeed } from '@bitgo/sdk-test'; -import { AbstractUtxoCoin, getReplayProtectionAddresses } from '../../src'; +import { + AbstractUtxoCoin, + getReplayProtectionAddresses, + ScriptType2Of3, + utxolibScriptTypes2Of3, + UtxolibScriptType, +} from '../../src'; import { getMainnetCoinName } from '../../src/names'; import { defaultBitGo, encryptKeychain, getDefaultWalletKeys, getUtxoWallet, keychainsBase58, utxoCoins } from './util'; @@ -24,7 +31,7 @@ type KeyDoc = { const walletPassphrase = 'gabagool'; const webauthnWalletPassPhrase = 'just the gabagool'; -const scriptTypes = [...utxolib.bitgo.outputScripts.scriptTypes2Of3, 'taprootKeyPathSpend', 'p2shP2pk'] as const; +const scriptTypes = [...utxolibScriptTypes2Of3, 'taprootKeyPathSpend', 'p2shP2pk'] as const; export type ScriptType = (typeof scriptTypes)[number]; type Input = { @@ -188,8 +195,8 @@ function run(coin: AbstractUtxoCoin, inputScripts: ScriptType[], txFormat: TxFor before(async function () { // Make output address information const outputAmount = BigInt(inputScripts.length) * BigInt(1e8) - fee; - const outputScriptType: utxolib.bitgo.outputScripts.ScriptType = 'p2sh'; - const outputChain = utxolib.bitgo.getExternalChainCode(outputScriptType); + const outputScriptType: UtxolibScriptType = 'p2sh'; + const outputChain = fixedScriptWallet.ChainCode.value(outputScriptType, 'external'); const outputAddress = utxolib.bitgo.getWalletAddress(rootWalletKeys, outputChain, 0, coin.network); recipient = { @@ -307,7 +314,7 @@ utxoCoins .forEach((inputScript) => { const inputScriptCleaned = ( inputScript === 'taprootKeyPathSpend' ? 'p2trMusig2' : inputScript - ) as utxolib.bitgo.outputScripts.ScriptType2Of3; + ) as ScriptType2Of3; if (!coin.supportsAddressType(inputScriptCleaned)) { return; diff --git a/modules/abstract-utxo/test/unit/recovery/backupKeyRecovery.ts b/modules/abstract-utxo/test/unit/recovery/backupKeyRecovery.ts index 6f114472ce..ec90679f1b 100644 --- a/modules/abstract-utxo/test/unit/recovery/backupKeyRecovery.ts +++ b/modules/abstract-utxo/test/unit/recovery/backupKeyRecovery.ts @@ -16,6 +16,8 @@ import { BackupKeyRecoveryTransansaction, CoingeckoApi, FormattedOfflineVaultTxInfo, + ScriptType2Of3, + toUtxolibScriptType, } from '../../../src'; import { getCoinName } from '../../../src/names'; import type { Unspent, WalletUnspent } from '../../../src/unspent'; @@ -37,7 +39,6 @@ import { MockRecoveryProvider } from './mock'; const { toOutput } = utxolib.bitgo; type RootWalletKeys = utxolib.bitgo.RootWalletKeys; -type ScriptType2Of3 = utxolib.bitgo.outputScripts.ScriptType2Of3; const config = { krsProviders }; @@ -154,9 +155,24 @@ function run( before('create recovery data', async function () { this.timeout(10_000); recoverUnspents = scriptTypes.flatMap((scriptType, index) => [ - utxolib.testutil.toUnspent({ scriptType, value: BigInt(1e8) * valueMul }, index, coin.network, walletKeys), - utxolib.testutil.toUnspent({ scriptType, value: BigInt(2e8) * valueMul }, index, coin.network, walletKeys), - utxolib.testutil.toUnspent({ scriptType, value: BigInt(3e8) * valueMul }, index, coin.network, walletKeys), + utxolib.testutil.toUnspent( + { scriptType: toUtxolibScriptType(scriptType), value: BigInt(1e8) * valueMul }, + index, + coin.network, + walletKeys + ), + utxolib.testutil.toUnspent( + { scriptType: toUtxolibScriptType(scriptType), value: BigInt(2e8) * valueMul }, + index, + coin.network, + walletKeys + ), + utxolib.testutil.toUnspent( + { scriptType: toUtxolibScriptType(scriptType), value: BigInt(3e8) * valueMul }, + index, + coin.network, + walletKeys + ), ]); // If the coin is bch, convert the mocked unspent address to cashaddr format since that is the format that blockchair diff --git a/modules/abstract-utxo/test/unit/util/unspents.ts b/modules/abstract-utxo/test/unit/util/unspents.ts index c8ee30dee4..7a89bfce20 100644 --- a/modules/abstract-utxo/test/unit/util/unspents.ts +++ b/modules/abstract-utxo/test/unit/util/unspents.ts @@ -2,29 +2,16 @@ import * as utxolib from '@bitgo/utxo-lib'; import { getSeed } from '@bitgo/sdk-test'; import * as wasmUtxo from '@bitgo/wasm-utxo'; -import { getReplayProtectionAddresses } from '../../../src'; +import { getReplayProtectionAddresses, ScriptType2Of3 } from '../../../src'; import { getCoinName } from '../../../src/names'; import type { Unspent, WalletUnspent } from '../../../src/unspent'; -const { scriptTypeForChain, chainCodesP2sh, getExternalChainCode, getInternalChainCode } = utxolib.bitgo; - type RootWalletKeys = utxolib.bitgo.RootWalletKeys; -type ChainCode = utxolib.bitgo.ChainCode; +type ChainCode = wasmUtxo.fixedScriptWallet.ChainCode; -export type InputScriptType = utxolib.bitgo.outputScripts.ScriptType2Of3 | 'replayProtection'; +export type InputScriptType = ScriptType2Of3 | 'replayProtection'; -const defaultChain: ChainCode = getExternalChainCode(chainCodesP2sh); - -export function getOutputScript( - walletKeys: RootWalletKeys, - chain = defaultChain, - index = 0 -): utxolib.bitgo.outputScripts.SpendableScript { - return utxolib.bitgo.outputScripts.createOutputScript2of3( - walletKeys.deriveForChainAndIndex(chain, index).publicKeys, - scriptTypeForChain(chain) - ); -} +const defaultChain: ChainCode = wasmUtxo.fixedScriptWallet.ChainCode.value('p2sh', 'external'); export function getWalletAddress( network: utxolib.Network, @@ -32,13 +19,7 @@ export function getWalletAddress( chain = defaultChain, index = 0 ): string { - if (utxolib.isTestnet(network)) { - return wasmUtxo.fixedScriptWallet.address(walletKeys, chain, index, network); - } - return wasmUtxo.address.fromOutputScriptWithCoin( - getOutputScript(walletKeys, chain, index).scriptPubKey, - getCoinName(network) - ); + return wasmUtxo.fixedScriptWallet.address(walletKeys, chain, index, network); } function mockOutputIdForAddress(address: string) { @@ -103,6 +84,10 @@ export function mockUnspent( if (chain === 'replayProtection') { return mockUnspentReplayProtection(network, (typeof value === 'bigint' ? BigInt(1000) : 1000) as TNumber); } else { - return mockWalletUnspent(network, walletKeys, { chain: getInternalChainCode(chain), value, index }); + // chain is either a ChainCode (number) or a ScriptType2Of3 (string) + const scriptType = + typeof chain === 'number' ? wasmUtxo.fixedScriptWallet.ChainCode.scriptType(chain) : (chain as ScriptType2Of3); + const internalChain = wasmUtxo.fixedScriptWallet.ChainCode.value(scriptType, 'internal'); + return mockWalletUnspent(network, walletKeys, { chain: internalChain, value, index }); } }