diff --git a/modules/abstract-utxo/src/abstractUtxoCoin.ts b/modules/abstract-utxo/src/abstractUtxoCoin.ts index efd345ce1e..c6a4b2f9ab 100644 --- a/modules/abstract-utxo/src/abstractUtxoCoin.ts +++ b/modules/abstract-utxo/src/abstractUtxoCoin.ts @@ -2,7 +2,7 @@ import assert from 'assert'; import { randomBytes } from 'crypto'; import _ from 'lodash'; -import { address as wasmAddress, BIP32, fixedScriptWallet, hasPsbtMagic } from '@bitgo/wasm-utxo'; +import { BIP32, fixedScriptWallet, hasPsbtMagic } from '@bitgo/wasm-utxo'; import { AddressCoinSpecific, BaseCoin, @@ -72,7 +72,15 @@ import { ErrorImplicitExternalOutputs, } from './transaction/descriptor/verifyTransaction'; import { assertDescriptorWalletAddress, getDescriptorMapFromWallet, isDescriptorWallet } from './descriptor'; -import { getFullNameFromCoinName, getMainnetCoinName, isMainnetCoin, UtxoCoinName, UtxoCoinNameMainnet } from './names'; +import { + getFullNameFromCoinName, + getMainnetCoinName, + toWasmUtxoCoinName, + isMainnetCoin, + WasmUtxoCoinName, + UtxoCoinName, + UtxoCoinNameMainnet, +} from './names'; import { assertFixedScriptWalletAddress, generateAddress } from './address/fixedScript'; import { ParsedTransaction } from './transaction/types'; import { decodeDescriptorPsbt, decodePsbt, encodeTransaction, stringToBufferTryFormats } from './transaction/decode'; @@ -414,6 +422,15 @@ export abstract class AbstractUtxoCoin extends BaseCoin implements Musig2Partici return getFullNameFromCoinName(this.name); } + /** Coin name used by wasm-utxo. Private Bitcoin networks may map to a shared codec. */ + get wasmName(): WasmUtxoCoinName { + return toWasmUtxoCoinName(this.name); + } + + get addressCodec(): AddressCodec { + return new AddressCodec(this.name, this.wasmName); + } + /** Indicates whether the coin supports a block target */ supportsBlockTarget(): boolean { // FIXME: the SDK does not seem to use this anywhere so it is unclear what the purpose of this method is @@ -461,27 +478,7 @@ export abstract class AbstractUtxoCoin extends BaseCoin implements Musig2Partici } } - // By default, allow all address formats. - // At the time of writing, the only additional address format is bch cashaddr. - const anyFormat = (param as { anyFormat: boolean } | undefined)?.anyFormat ?? true; - try { - const script = wasmAddress.toOutputScriptWithCoin(address, this.name); - // Determine which format the input address was in by round-tripping - // through each candidate and checking byte-equality. 'default' is tried - // first so canonical default-format addresses early-exit. - for (const format of ['default', 'cashaddr'] as const) { - try { - if (wasmAddress.fromOutputScriptWithCoin(script, this.name, format) === address) { - return anyFormat || format === 'default'; - } - } catch { - // coin doesn't support this format; try the next one - } - } - return false; - } catch (e) { - return false; - } + return this.addressCodec.isValidAddress(address); } /** @@ -568,7 +565,7 @@ export abstract class AbstractUtxoCoin extends BaseCoin implements Musig2Partici if (!hasPsbtMagic(buffer)) { throw new ErrorDeprecatedTxFormat('legacy'); } - return decodePsbt(buffer, this.name); + return decodePsbt(buffer, this.wasmName); } decodeTransactionAsPsbt(input: Buffer | string): fixedScriptWallet.BitGoPsbt { @@ -596,7 +593,7 @@ export abstract class AbstractUtxoCoin extends BaseCoin implements Musig2Partici async parseTransaction( params: ParseTransactionOptions ): Promise> { - return this.parseTransactionWithAddressCodec(params, new AddressCodec(this.name)); + return this.parseTransactionWithAddressCodec(params, this.addressCodec); } protected parseTransactionWithAddressCodec( @@ -639,7 +636,7 @@ export abstract class AbstractUtxoCoin extends BaseCoin implements Musig2Partici async verifyTransaction( params: VerifyTransactionOptions ): Promise { - return this.verifyTransactionWithAddressCodec(params, new AddressCodec(this.name)); + return this.verifyTransactionWithAddressCodec(params, this.addressCodec); } protected async verifyTransactionWithAddressCodec( @@ -693,7 +690,7 @@ export abstract class AbstractUtxoCoin extends BaseCoin implements Musig2Partici throw new Error('keychains must be a triple'); } assertDescriptorWalletAddress( - this.name, + this.addressCodec, params, getDescriptorMapFromWallet(wallet, toBip32Triple(keychains), getPolicyForEnv(this.bitgo.env)) ); @@ -710,7 +707,7 @@ export abstract class AbstractUtxoCoin extends BaseCoin implements Musig2Partici throw new Error('missing required param keychains'); } - assertFixedScriptWalletAddress(this.name, { + assertFixedScriptWalletAddress(this.wasmName, { address, keychains, format: params.format ?? 'base58', @@ -739,7 +736,7 @@ export abstract class AbstractUtxoCoin extends BaseCoin implements Musig2Partici throw new Error('missing required param keychains'); } - const address = generateAddress(this.name, { + const address = generateAddress(this.wasmName, { // fixed-script (multisig) coins derive from the xpub triple via `pub` keychains: keychains as { pub: string }[], chain, @@ -755,7 +752,7 @@ export abstract class AbstractUtxoCoin extends BaseCoin implements Musig2Partici * @returns true iff coin supports spending from unspentType */ supportsAddressType(addressType: ScriptType2Of3): boolean { - return fixedScriptWallet.supportsScriptType(this.name, addressType); + return fixedScriptWallet.supportsScriptType(this.wasmName, addressType); } /** inherited doc */ @@ -789,7 +786,7 @@ export abstract class AbstractUtxoCoin extends BaseCoin implements Musig2Partici .post(this.url('/wallet/' + walletId + '/tx/signpsbt')) .send({ psbt: buffer.toString('hex') }) .result(); - return decodePsbt(response.psbt, this.name); + return decodePsbt(response.psbt, this.wasmName); } /** @@ -907,9 +904,9 @@ export abstract class AbstractUtxoCoin extends BaseCoin implements Musig2Partici if (wallet && isDescriptorWallet(wallet)) { // Descriptor wallets decode prebuild bytes straight into the wasm-utxo // descriptor Psbt, skipping the fixedScriptWallet.BitGoPsbt intermediate. - return explainTx(decodeDescriptorPsbt(params), { ...params, wallet }, this.name); + return explainTx(decodeDescriptorPsbt(params), { ...params, wallet }, this.wasmName); } - return explainTx(this.decodeTransactionFromPrebuild(params), { ...params, wallet }, this.name); + return explainTx(this.decodeTransactionFromPrebuild(params), { ...params, wallet }, this.wasmName); } /** diff --git a/modules/abstract-utxo/src/address/fixedScript.ts b/modules/abstract-utxo/src/address/fixedScript.ts index fd3002124a..8f889b665c 100644 --- a/modules/abstract-utxo/src/address/fixedScript.ts +++ b/modules/abstract-utxo/src/address/fixedScript.ts @@ -14,7 +14,7 @@ import { } from '@bitgo/sdk-core'; import { fixedScriptWallet } from '@bitgo/wasm-utxo'; -import { UtxoCoinName } from '../names'; +import { toWasmUtxoCoinName, UtxoCoinName, WasmUtxoCoinName } from '../names'; type ScriptType2Of3 = fixedScriptWallet.OutputScriptType; type ChainCode = fixedScriptWallet.ChainCode; @@ -35,8 +35,8 @@ interface GenerateFixedScriptAddressOptions extends GenerateAddressOptions { keychains: { pub: string }[]; } -function supportsAddressType(coinName: UtxoCoinName, addressType: ScriptType2Of3): boolean { - return fixedScriptWallet.supportsScriptType(coinName, addressType); +function supportsAddressType(coinName: UtxoCoinName | WasmUtxoCoinName, addressType: ScriptType2Of3): boolean { + return fixedScriptWallet.supportsScriptType(toWasmUtxoCoinName(coinName), addressType); } /** @@ -47,7 +47,7 @@ function normalizeScriptType(scriptType: ScriptType2Of3 | 'p2tr'): ScriptType2Of } export function generateAddressWithChainAndIndex( - coinName: UtxoCoinName, + coinName: UtxoCoinName | WasmUtxoCoinName, keychains: fixedScriptWallet.WalletKeysArg | Triple, chain: ChainCode, index: number, @@ -56,7 +56,7 @@ export function generateAddressWithChainAndIndex( // Convert CreateAddressFormat to AddressFormat for wasm-utxo // 'base58' -> 'default', 'cashaddr' -> 'cashaddr' const wasmFormat = format === 'base58' ? 'default' : format; - return fixedScriptWallet.address(keychains, chain, index, coinName, wasmFormat); + return fixedScriptWallet.address(keychains, chain, index, toWasmUtxoCoinName(coinName), wasmFormat); } /** @@ -70,7 +70,10 @@ export function generateAddressWithChainAndIndex( * @param params.bech32 {boolean} Deprecated * @returns {string} The generated address */ -export function generateAddress(coinName: UtxoCoinName, params: GenerateFixedScriptAddressOptions): string { +export function generateAddress( + coinName: UtxoCoinName | WasmUtxoCoinName, + params: GenerateFixedScriptAddressOptions +): string { let derivationIndex = 0; if (_.isInteger(params.index) && (params.index as number) > 0) { derivationIndex = params.index as number; @@ -142,7 +145,7 @@ type Keychain = { }; export function assertFixedScriptWalletAddress( - coinName: UtxoCoinName, + coinName: UtxoCoinName | WasmUtxoCoinName, { chain, index, diff --git a/modules/abstract-utxo/src/descriptor/assertDescriptorWalletAddress.ts b/modules/abstract-utxo/src/descriptor/assertDescriptorWalletAddress.ts index aefa7d3bc0..d5d2a86960 100644 --- a/modules/abstract-utxo/src/descriptor/assertDescriptorWalletAddress.ts +++ b/modules/abstract-utxo/src/descriptor/assertDescriptorWalletAddress.ts @@ -1,9 +1,9 @@ import assert from 'assert'; -import { Descriptor, address, descriptorWallet } from '@bitgo/wasm-utxo'; +import { Descriptor, descriptorWallet } from '@bitgo/wasm-utxo'; import { UtxoCoinSpecific, VerifyAddressOptions } from '../abstractUtxoCoin'; -import { UtxoCoinName } from '../names'; +import { AddressCodec } from '../transaction/recipient'; class DescriptorAddressMismatchError extends Error { constructor(descriptor: Descriptor, index: number, derivedAddress: string, expectedAddress: string) { @@ -14,7 +14,7 @@ class DescriptorAddressMismatchError extends Error { } export function assertDescriptorWalletAddress( - coinName: UtxoCoinName, + addressCodec: AddressCodec, params: VerifyAddressOptions, descriptors: descriptorWallet.DescriptorMap ): void { @@ -33,7 +33,7 @@ export function assertDescriptorWalletAddress( ); } const derivedScript = Buffer.from(descriptor.atDerivationIndex(params.index).scriptPubkey()); - const derivedAddress = address.fromOutputScriptWithCoin(derivedScript, coinName); + const derivedAddress = addressCodec.toExtendedAddressFormat(derivedScript); if (params.address !== derivedAddress) { throw new DescriptorAddressMismatchError(descriptor, params.index, derivedAddress, params.address); } diff --git a/modules/abstract-utxo/src/impl/bch/bch.ts b/modules/abstract-utxo/src/impl/bch/bch.ts index 50fcb966a5..3eea1c5415 100644 --- a/modules/abstract-utxo/src/impl/bch/bch.ts +++ b/modules/abstract-utxo/src/impl/bch/bch.ts @@ -2,9 +2,21 @@ import { BitGoBase } from '@bitgo/sdk-core'; import { address as wasmAddress } from '@bitgo/wasm-utxo'; import { AbstractUtxoCoin } from '../../abstractUtxoCoin'; -import { UtxoCoinName } from '../../names'; +import { UtxoCoinName, WasmUtxoCoinName } from '../../names'; import { AddressCodec } from '../../transaction'; +type BchAddressFormat = 'default' | 'cashaddr'; + +class BchAddressCodec extends AddressCodec { + constructor(coinName: UtxoCoinName, wasmName: WasmUtxoCoinName, private readonly format: BchAddressFormat) { + super(coinName, wasmName); + } + + override encode(script: Uint8Array): string { + return wasmAddress.fromOutputScriptWithCoin(script, this.wasmName, this.format); + } +} + export class Bch extends AbstractUtxoCoin { readonly name: UtxoCoinName = 'bch'; @@ -16,6 +28,26 @@ export class Bch extends AbstractUtxoCoin { return new Bch(bitgo); } + private getBchAddressCodec(format: BchAddressFormat): BchAddressCodec { + return new BchAddressCodec(this.name, this.wasmName, format); + } + + override get addressCodec(): BchAddressCodec { + return this.getBchAddressCodec('default'); + } + + override isValidAddress( + address: string, + param?: { anyFormat?: boolean; allowLightning?: boolean } | boolean + ): boolean { + const anyFormat = typeof param === 'object' ? param?.anyFormat ?? true : true; + const isDefaultAddress = super.isValidAddress(address, param); + if (isDefaultAddress || !anyFormat) { + return isDefaultAddress; + } + return this.getBchAddressCodec('cashaddr').isValidAddress(address); + } + /** * Canonicalize a Bitcoin Cash address for a specific version * @@ -34,13 +66,14 @@ export class Bch extends AbstractUtxoCoin { } if (version === 'base58') { - const script = wasmAddress.toOutputScriptWithCoin(address, this.name); - return wasmAddress.fromOutputScriptWithCoin(script, this.name, 'default'); + const codec = this.addressCodec; + const script = codec.decode(address); + return codec.encode(script); } if (version === 'cashaddr') { - const script = wasmAddress.toOutputScriptWithCoin(address, this.name); - return wasmAddress.fromOutputScriptWithCoin(script, this.name, 'cashaddr'); + const codec = this.getBchAddressCodec('cashaddr'); + return codec.encode(codec.decode(address)); } throw new Error(`invalid version ${version}`); diff --git a/modules/abstract-utxo/src/impl/btc/inscriptionBuilder.ts b/modules/abstract-utxo/src/impl/btc/inscriptionBuilder.ts index 2262066d1b..c54edcd446 100644 --- a/modules/abstract-utxo/src/impl/btc/inscriptionBuilder.ts +++ b/modules/abstract-utxo/src/impl/btc/inscriptionBuilder.ts @@ -63,7 +63,7 @@ export class InscriptionBuilder implements IInscriptionBuilder { derivedKey.publicKey, contentType, inscriptionData, - this.coin.name + this.coin.wasmName ); // Convert TapLeafScript to utxolib format for backwards compatibility @@ -121,7 +121,7 @@ export class InscriptionBuilder implements IInscriptionBuilder { } const psbt = createPsbtForSingleInscriptionPassingTransaction( - this.coin.name, + this.coin.wasmName, { walletKeys: walletXpubs, signer, @@ -279,7 +279,7 @@ export class InscriptionBuilder implements IInscriptionBuilder { commitAddress, recipientAddress, Buffer.from(halfSignedCommitTransaction.txHex, 'hex'), - this.coin.name + this.coin.wasmName ); return this.wallet.submitTransaction({ diff --git a/modules/abstract-utxo/src/impl/zec/zec.ts b/modules/abstract-utxo/src/impl/zec/zec.ts index f20e03d628..cd489ef1be 100644 --- a/modules/abstract-utxo/src/impl/zec/zec.ts +++ b/modules/abstract-utxo/src/impl/zec/zec.ts @@ -20,7 +20,8 @@ export class Zec extends AbstractUtxoCoin { isValidAddress(address: string, param?: { anyFormat?: boolean; allowLightning?: boolean } | boolean): boolean { return ( - zcashAddress.hasTransparentReceiver(address, this.name) || zcashAddress.hasOrchardReceiver(address, this.name) + zcashAddress.hasTransparentReceiver(address, this.wasmName) || + zcashAddress.hasOrchardReceiver(address, this.wasmName) ); } } diff --git a/modules/abstract-utxo/src/names.ts b/modules/abstract-utxo/src/names.ts index 71f37f1318..8dab6448e9 100644 --- a/modules/abstract-utxo/src/names.ts +++ b/modules/abstract-utxo/src/names.ts @@ -1,3 +1,7 @@ +import { isCoinName, type CoinName } from '@bitgo/wasm-utxo'; + +export type WasmUtxoCoinName = CoinName; + export const utxoCoinsMainnet = ['btc', 'bch', 'bcha', 'bsv', 'btg', 'dash', 'doge', 'ltc', 'pearl', 'zec'] as const; export const utxoCoinsTestnet = [ 'tbtc', @@ -19,6 +23,13 @@ export type UtxoCoinNameMainnet = (typeof utxoCoinsMainnet)[number]; export type UtxoCoinNameTestnet = `t${UtxoCoinNameMainnet}` | 'tbtcsig' | 'tbtc4' | 'tbtcbgsig'; export type UtxoCoinName = UtxoCoinNameMainnet | UtxoCoinNameTestnet; +export function toWasmUtxoCoinName(coinName: UtxoCoinName | WasmUtxoCoinName): WasmUtxoCoinName { + if (!isCoinName(coinName)) { + throw new Error(`coin ${coinName} is not supported by wasm-utxo`); + } + return coinName; +} + export function isUtxoCoinNameMainnet(coinName: string): coinName is UtxoCoinNameMainnet { return utxoCoinsMainnet.includes(coinName as UtxoCoinNameMainnet); } diff --git a/modules/abstract-utxo/src/recovery/backupKeyRecovery.ts b/modules/abstract-utxo/src/recovery/backupKeyRecovery.ts index 0e0e2dde7a..462c209fcb 100644 --- a/modules/abstract-utxo/src/recovery/backupKeyRecovery.ts +++ b/modules/abstract-utxo/src/recovery/backupKeyRecovery.ts @@ -15,7 +15,7 @@ import { signAndVerifyPsbt } from '../transaction/fixedScript/signTransaction'; import { generateAddressWithChainAndIndex } from '../address'; import { encodeTransaction } from '../transaction/decode'; import { getReplayProtectionPubkeys } from '../transaction/fixedScript/replayProtection'; -import { getMainnetCoinName, UtxoCoinName } from '../names'; +import { getMainnetCoinName, toWasmUtxoCoinName, UtxoCoinName, WasmUtxoCoinName } from '../names'; import { parseOutputId, unspentSum, type WalletUnspent } from '../unspent'; import { forCoin, RecoveryProvider } from './RecoveryProvider'; @@ -123,7 +123,7 @@ export interface RecoverParams { */ function getFormattedAddress( coin: AbstractUtxoCoin, - coinName: UtxoCoinName, + coinName: WasmUtxoCoinName, walletKeys: fixedScriptWallet.RootWalletKeys, chain: ChainCode, addrIndex: number @@ -162,7 +162,7 @@ async function queryBlockchainUnspentsPath( } async function gatherUnspents(addrIndex: number) { - const formattedAddress = getFormattedAddress(coin, coin.name, walletKeys, chain, addrIndex); + const formattedAddress = getFormattedAddress(coin, coin.wasmName, walletKeys, chain, addrIndex); const addrInfo = await recoveryProvider.getAddressInfo(formattedAddress); // we use txCount here because it implies usage - having tx'es means the addr was generated and used if (addrInfo.txCount === 0) { @@ -297,7 +297,7 @@ function hasPrivateKey(key: BIP32): boolean { * @returns The PSBT at the appropriate signing stage (never finalized) */ export function backupKeyRecoveryWithWalletUnspents( - coinName: UtxoCoinName, + coinName: UtxoCoinName | WasmUtxoCoinName, params: RecoverWithUnspentsParams, unspents: WalletUnspent[] ): fixedScriptWallet.BitGoPsbt { @@ -308,7 +308,7 @@ export function backupKeyRecoveryWithWalletUnspents( throw new ErrorNoInputToRecover(); } - let psbt = createBackupKeyRecoveryPsbt(coinName, walletKeys, unspents, { + let psbt = createBackupKeyRecoveryPsbt(toWasmUtxoCoinName(coinName), walletKeys, unspents, { feeRateSatVB: feeRateSatVB, recoveryDestination: recoveryDestination, keyRecoveryServiceFee: krsFee ?? BigInt(0), @@ -496,7 +496,7 @@ export async function backupKeyRecovery( fixedScriptWallet.outputScriptTypes .filter( (addressType) => - fixedScriptWallet.supportsScriptType(coin.name, addressType) && + fixedScriptWallet.supportsScriptType(coin.wasmName, addressType) && !params.ignoreAddressTypes?.includes(addressType) ) .reduce( @@ -557,7 +557,7 @@ export async function backupKeyRecovery( // Build and sign PSBT const psbt = backupKeyRecoveryWithWalletUnspents( - coin.name, + coin.wasmName, { walletKeys, keys, diff --git a/modules/abstract-utxo/src/recovery/crossChainRecovery.ts b/modules/abstract-utxo/src/recovery/crossChainRecovery.ts index 4455b1b297..05350fde5e 100644 --- a/modules/abstract-utxo/src/recovery/crossChainRecovery.ts +++ b/modules/abstract-utxo/src/recovery/crossChainRecovery.ts @@ -1,10 +1,10 @@ -import { BIP32, CoinName, fixedScriptWallet, address as wasmAddress } from '@bitgo/wasm-utxo'; +import { BIP32, fixedScriptWallet, address as wasmAddress } from '@bitgo/wasm-utxo'; import { decrypt } from '@bitgo/sdk-api'; import { BitGoBase, IWallet, Keychain, Triple, Wallet } from '@bitgo/sdk-core'; import { AbstractUtxoCoin, TransactionInfo } from '../abstractUtxoCoin'; import { signAndVerifyPsbt } from '../transaction/fixedScript/signTransaction'; -import { UtxoCoinName } from '../names'; +import { UtxoCoinName, WasmUtxoCoinName } from '../names'; import { encodeTransaction } from '../transaction/decode'; import { getReplayProtectionPubkeys } from '../transaction/fixedScript/replayProtection'; import { toTNumber } from '../tnumber'; @@ -206,7 +206,7 @@ type ScriptId = { }; async function getScriptId(coin: AbstractUtxoCoin, wallet: IWallet | WalletV1, script: Uint8Array): Promise { - const address = wasmAddress.fromOutputScriptWithCoin(script, coin.name); + const address = coin.addressCodec.encode(script); let addressData: { chain: number; index: number }; if (wallet instanceof Wallet) { addressData = await wallet.getAddress({ address }); @@ -242,7 +242,7 @@ async function toWalletUnspents( for (const address of addresses) { let scriptId; try { - scriptId = await getScriptId(recoveryCoin, wallet, wasmAddress.toOutputScriptWithCoin(address, sourceCoin.name)); + scriptId = await getScriptId(recoveryCoin, wallet, sourceCoin.addressCodec.decode(address)); } catch (e) { console.error(`error getting scriptId for ${address}:`, e); continue; @@ -329,7 +329,7 @@ async function getPrv(xprv?: string, passphrase?: string, wallet?: IWallet | Wal * @return unsigned PSBT */ function createSweepTransaction( - coinName: CoinName, + coinName: WasmUtxoCoinName, walletKeys: fixedScriptWallet.RootWalletKeys, unspents: WalletUnspent[], targetAddress: string, @@ -403,7 +403,7 @@ export async function recoverCrossChain( - params.sourceCoin.getChain(), + params.sourceCoin.wasmName, walletKeys, walletUnspents, params.recoveryAddress, diff --git a/modules/abstract-utxo/src/transaction/decode.ts b/modules/abstract-utxo/src/transaction/decode.ts index 35f4337582..21bc80633b 100644 --- a/modules/abstract-utxo/src/transaction/decode.ts +++ b/modules/abstract-utxo/src/transaction/decode.ts @@ -1,6 +1,6 @@ import { fixedScriptWallet, hasPsbtMagic, Psbt as WasmPsbt } from '@bitgo/wasm-utxo'; -import { UtxoCoinName } from '../names'; +import { toWasmUtxoCoinName, UtxoCoinName, WasmUtxoCoinName } from '../names'; import { BitGoPsbt } from './types'; @@ -21,11 +21,11 @@ export function stringToBufferTryFormats(input: string, formats: BufferEncoding[ throw new Error('input must be a valid hex or base64 string'); } -export function decodePsbt(psbt: string | Buffer, coinName: UtxoCoinName): BitGoPsbt { +export function decodePsbt(psbt: string | Buffer, coinName: UtxoCoinName | WasmUtxoCoinName): BitGoPsbt { if (typeof psbt === 'string') { psbt = Buffer.from(psbt, 'hex'); } - return fixedScriptWallet.BitGoPsbt.fromBytes(psbt, coinName); + return fixedScriptWallet.BitGoPsbt.fromBytes(psbt, toWasmUtxoCoinName(coinName)); } export type PrebuildLike = { diff --git a/modules/abstract-utxo/src/transaction/descriptor/explainPsbt.ts b/modules/abstract-utxo/src/transaction/descriptor/explainPsbt.ts index f817b14efe..cf7933ca2e 100644 --- a/modules/abstract-utxo/src/transaction/descriptor/explainPsbt.ts +++ b/modules/abstract-utxo/src/transaction/descriptor/explainPsbt.ts @@ -2,13 +2,13 @@ import { ITransactionRecipient } from '@bitgo/sdk-core'; import { Psbt, descriptorWallet } from '@bitgo/wasm-utxo'; import type { TransactionExplanationDescriptor } from '../fixedScript/explainTransaction'; -import { UtxoCoinName } from '../../names'; +import { toWasmUtxoCoinName, UtxoCoinName, WasmUtxoCoinName } from '../../names'; function sumValues(arr: { value: bigint }[]): bigint { return arr.reduce((sum, e) => sum + e.value, 0n); } -function toRecipient(output: descriptorWallet.ParsedOutput, coinName: UtxoCoinName): ITransactionRecipient { +function toRecipient(output: descriptorWallet.ParsedOutput): ITransactionRecipient { const address = output.address ?? `scriptPubKey:${Buffer.from(output.script).toString('hex')}`; return { address, @@ -34,9 +34,9 @@ function getInputSignatures(psbt: Psbt): number[] { export function explainPsbt( psbt: Psbt, descriptors: descriptorWallet.DescriptorMap, - coinName: UtxoCoinName + coinName: UtxoCoinName | WasmUtxoCoinName ): TransactionExplanationDescriptor { - const parsedTransaction = descriptorWallet.parse(psbt, descriptors, coinName); + const parsedTransaction = descriptorWallet.parse(psbt, descriptors, toWasmUtxoCoinName(coinName)); const { inputs, outputs } = parsedTransaction; const externalOutputs = outputs.filter((o) => o.scriptId === undefined); const changeOutputs = outputs.filter((o) => o.scriptId !== undefined); @@ -47,9 +47,9 @@ export function explainPsbt( signatures: inputSignatures.reduce((a, b) => Math.min(a, b), Infinity), locktime: psbt.lockTime(), id: psbt.unsignedTxId(), - outputs: externalOutputs.map((o) => toRecipient(o, coinName)), + outputs: externalOutputs.map(toRecipient), outputAmount: sumValues(externalOutputs).toString(), - changeOutputs: changeOutputs.map((o) => toRecipient(o, coinName)), + changeOutputs: changeOutputs.map(toRecipient), changeAmount: sumValues(changeOutputs).toString(), fee: fee.toString(), }; diff --git a/modules/abstract-utxo/src/transaction/descriptor/parse.ts b/modules/abstract-utxo/src/transaction/descriptor/parse.ts index 72459ce752..851e45524c 100644 --- a/modules/abstract-utxo/src/transaction/descriptor/parse.ts +++ b/modules/abstract-utxo/src/transaction/descriptor/parse.ts @@ -41,7 +41,7 @@ function parseOutputsWithPsbt( recipientOutputs: RecipientOutput[], addressCodec: AddressCodec ): ParsedOutputs { - const parsed = descriptorWallet.parse(psbt, descriptorMap, addressCodec.coinName); + const parsed = descriptorWallet.parse(psbt, descriptorMap, addressCodec.wasmName); const outputs: ParsedOutput[] = parsed.outputs.map((output) => ({ ...output, script: Buffer.from(output.script), @@ -116,7 +116,7 @@ export function parse( coin: AbstractUtxoCoin, wallet: IDescriptorWallet, params: ParseTransactionOptions, - addressCodec: AddressCodec = new AddressCodec(coin.name) + addressCodec: AddressCodec = coin.addressCodec ): ParsedDescriptorTransaction { if (params.txParams.allowExternalChangeAddress) { throw new Error('allowExternalChangeAddress is not supported for descriptor wallets'); diff --git a/modules/abstract-utxo/src/transaction/descriptor/parseToAmountType.ts b/modules/abstract-utxo/src/transaction/descriptor/parseToAmountType.ts index 1decb0e19f..1637b62f6e 100644 --- a/modules/abstract-utxo/src/transaction/descriptor/parseToAmountType.ts +++ b/modules/abstract-utxo/src/transaction/descriptor/parseToAmountType.ts @@ -78,7 +78,7 @@ export function parseToAmountType( coin: AbstractUtxoCoin, wallet: IDescriptorWallet, params: ParseTransactionOptions, - addressCodec: AddressCodec = new AddressCodec(coin.name) + addressCodec: AddressCodec = coin.addressCodec ): BaseParsedTransaction> { return parsedDescriptorTransactionToTNumber>(parse(coin, wallet, params, addressCodec), { amountTypeAggregate: coin.amountType, diff --git a/modules/abstract-utxo/src/transaction/descriptor/verifyTransaction.ts b/modules/abstract-utxo/src/transaction/descriptor/verifyTransaction.ts index bef56d839b..381bec9cdd 100644 --- a/modules/abstract-utxo/src/transaction/descriptor/verifyTransaction.ts +++ b/modules/abstract-utxo/src/transaction/descriptor/verifyTransaction.ts @@ -75,7 +75,7 @@ export async function verifyTransaction( coin: AbstractUtxoCoin, params: VerifyTransactionOptions, descriptorMap: descriptorWallet.DescriptorMap, - addressCodec: AddressCodec = new AddressCodec(coin.name) + addressCodec: AddressCodec = coin.addressCodec ): Promise { let psbt: Psbt; try { diff --git a/modules/abstract-utxo/src/transaction/explainTransaction.ts b/modules/abstract-utxo/src/transaction/explainTransaction.ts index 9da1ecc890..bb6642a919 100644 --- a/modules/abstract-utxo/src/transaction/explainTransaction.ts +++ b/modules/abstract-utxo/src/transaction/explainTransaction.ts @@ -4,7 +4,7 @@ import { isTriple, IWallet, Triple } from '@bitgo/sdk-core'; import { getDescriptorMapFromWallet, isDescriptorWallet } from '../descriptor'; import { toBip32Triple } from '../keychains'; import { getPolicyForEnv } from '../descriptor/validatePolicy'; -import { UtxoCoinName } from '../names'; +import { UtxoCoinName, WasmUtxoCoinName } from '../names'; import type { Unspent } from '../unspent'; import { getReplayProtectionPubkeys } from './fixedScript/replayProtection'; @@ -24,7 +24,7 @@ export function explainTx( customChangeXpubs?: Triple; txInfo?: { unspents?: Unspent[] }; }, - coinName: UtxoCoinName + coinName: UtxoCoinName | WasmUtxoCoinName ): TransactionExplanationUtxolibPsbt | TransactionExplanationWasm { if (params.wallet && isDescriptorWallet(params.wallet)) { if (!(tx instanceof WasmPsbt)) { diff --git a/modules/abstract-utxo/src/transaction/fixedScript/parseTransaction.ts b/modules/abstract-utxo/src/transaction/fixedScript/parseTransaction.ts index 03435e5310..5391478609 100644 --- a/modules/abstract-utxo/src/transaction/fixedScript/parseTransaction.ts +++ b/modules/abstract-utxo/src/transaction/fixedScript/parseTransaction.ts @@ -44,7 +44,7 @@ function toCanonicalTransactionRecipient( async function parseRbfTransaction( coin: AbstractUtxoCoin, params: ParseTransactionOptions, - addressCodec: AddressCodec = new AddressCodec(coin.name) + addressCodec: AddressCodec = coin.addressCodec ): Promise> { const { txParams, wallet } = params; @@ -136,7 +136,7 @@ function verifyCustomChangeKeys(userKeychain: UtxoKeychain, customChange: Custom export async function parseTransaction( coin: AbstractUtxoCoin, params: ParseTransactionOptions, - addressCodec: AddressCodec = new AddressCodec(coin.name) + addressCodec: AddressCodec = coin.addressCodec ): Promise> { const { txParams, txPrebuild, wallet, verification = {}, reqId } = params; diff --git a/modules/abstract-utxo/src/transaction/fixedScript/replayProtection.ts b/modules/abstract-utxo/src/transaction/fixedScript/replayProtection.ts index 81fecf0cac..730f4253c4 100644 --- a/modules/abstract-utxo/src/transaction/fixedScript/replayProtection.ts +++ b/modules/abstract-utxo/src/transaction/fixedScript/replayProtection.ts @@ -1,12 +1,12 @@ import { address, type AddressFormat } from '@bitgo/wasm-utxo'; -import { UtxoCoinName } from '../../names'; +import { UtxoCoinName, WasmUtxoCoinName } from '../../names'; export const pubkeyProd = Buffer.from('0255b9f71ac2c78fffd83e3e37b9e17ae70d5437b7f56d0ed2e93b7de08015aa59', 'hex'); export const pubkeyTestnet = Buffer.from('0219da48412c2268865fe8c126327d1b12eee350a3b69eb09e3323cc9a11828945', 'hex'); -export function getReplayProtectionPubkeys(coinName: UtxoCoinName): Buffer[] { +export function getReplayProtectionPubkeys(coinName: UtxoCoinName | WasmUtxoCoinName): Buffer[] { switch (coinName) { case 'bch': case 'bsv': @@ -27,7 +27,10 @@ const replayProtectionScriptsProd = [Buffer.from('a914174315cfde84f4c45395ac6f15 // bchtest:pqtjmnzwqffkrk2349g3cecfwwjwxusvnq87n07cal const replayProtectionScriptsTestnet = [Buffer.from('a914172dcc4e025361d951a9511c670973a4e3720c9887', 'hex')]; -export function getReplayProtectionAddresses(coinName: UtxoCoinName, format: AddressFormat = 'default'): string[] { +export function getReplayProtectionAddresses( + coinName: UtxoCoinName | WasmUtxoCoinName, + format: AddressFormat = 'default' +): string[] { switch (coinName) { case 'bch': case 'bsv': @@ -40,6 +43,6 @@ export function getReplayProtectionAddresses(coinName: UtxoCoinName, format: Add } } -export function isReplayProtectionUnspent(u: { address: string }, coinName: UtxoCoinName): boolean { +export function isReplayProtectionUnspent(u: { address: string }, coinName: UtxoCoinName | WasmUtxoCoinName): boolean { return getReplayProtectionAddresses(coinName).includes(u.address); } diff --git a/modules/abstract-utxo/src/transaction/fixedScript/signTransaction.ts b/modules/abstract-utxo/src/transaction/fixedScript/signTransaction.ts index a9dec08532..ea23d80bd6 100644 --- a/modules/abstract-utxo/src/transaction/fixedScript/signTransaction.ts +++ b/modules/abstract-utxo/src/transaction/fixedScript/signTransaction.ts @@ -4,7 +4,7 @@ import { isTriple } from '@bitgo/sdk-core'; import _ from 'lodash'; import { BIP32, bip32, fixedScriptWallet } from '@bitgo/wasm-utxo'; -import { UtxoCoinName } from '../../names'; +import { UtxoCoinName, WasmUtxoCoinName } from '../../names'; import type { Unspent } from '../../unspent'; import { Musig2Participant } from './musig2'; @@ -27,7 +27,7 @@ export async function signTransaction( coin: Musig2Participant, tx: fixedScriptWallet.BitGoPsbt, signerKeychain: bip32.BIP32Interface | undefined, - coinName: UtxoCoinName, + coinName: UtxoCoinName | WasmUtxoCoinName, params: { walletId: string | undefined; txInfo: { unspents?: Unspent[] } | undefined; diff --git a/modules/abstract-utxo/src/transaction/parseTransaction.ts b/modules/abstract-utxo/src/transaction/parseTransaction.ts index 965686154d..fbda748d1b 100644 --- a/modules/abstract-utxo/src/transaction/parseTransaction.ts +++ b/modules/abstract-utxo/src/transaction/parseTransaction.ts @@ -9,7 +9,7 @@ import * as fixedScript from './fixedScript'; export async function parseTransaction( coin: AbstractUtxoCoin, params: ParseTransactionOptions, - addressCodec: AddressCodec = new AddressCodec(coin.name) + addressCodec: AddressCodec = coin.addressCodec ): Promise> { if (isDescriptorWallet(params.wallet)) { return descriptor.parseToAmountType(coin, params.wallet, params, addressCodec); diff --git a/modules/abstract-utxo/src/transaction/recipient.ts b/modules/abstract-utxo/src/transaction/recipient.ts index 1642b7de98..d17c67fc65 100644 --- a/modules/abstract-utxo/src/transaction/recipient.ts +++ b/modules/abstract-utxo/src/transaction/recipient.ts @@ -1,13 +1,16 @@ import { address as wasmAddress } from '@bitgo/wasm-utxo'; -import { UtxoCoinName } from '../names'; +import { toWasmUtxoCoinName, UtxoCoinName, WasmUtxoCoinName } from '../names'; const ScriptRecipientPrefix = 'scriptPubKey:'; const OP_RETURN = 0x6a; -/** Address/network-aware recipient conversion with overridable address decoding. */ +/** Address/network-aware recipient conversion. */ export class AddressCodec { - constructor(public readonly coinName: UtxoCoinName) {} + constructor( + public readonly coinName: UtxoCoinName, + public readonly wasmName: WasmUtxoCoinName = toWasmUtxoCoinName(coinName) + ) {} /** Check if the address is a script recipient (starts with `scriptPubKey:`). */ static isScriptRecipient(address: string): boolean { @@ -35,7 +38,19 @@ export class AddressCodec { } decode(address: string): Uint8Array { - return wasmAddress.toOutputScriptWithCoin(address, this.coinName); + return wasmAddress.toOutputScriptWithCoin(address, this.wasmName); + } + + encode(script: Uint8Array): string { + return wasmAddress.fromOutputScriptWithCoin(script, this.wasmName); + } + + isValidAddress(address: string): boolean { + try { + return this.encode(this.decode(address)) === address; + } catch { + return false; + } } fromExtendedAddressFormatToScript(extendedAddress: string): Buffer { @@ -60,9 +75,7 @@ export class AddressCodec { } toExtendedAddressFormat(script: Buffer): string { - return script[0] === OP_RETURN - ? `${ScriptRecipientPrefix}${script.toString('hex')}` - : wasmAddress.fromOutputScriptWithCoin(script, this.coinName); + return script[0] === OP_RETURN ? `${ScriptRecipientPrefix}${script.toString('hex')}` : this.encode(script); } } diff --git a/modules/abstract-utxo/src/transaction/signTransaction.ts b/modules/abstract-utxo/src/transaction/signTransaction.ts index 7a08d0b79f..bb4ca9c5ef 100644 --- a/modules/abstract-utxo/src/transaction/signTransaction.ts +++ b/modules/abstract-utxo/src/transaction/signTransaction.ts @@ -60,7 +60,7 @@ export async function signTransaction( } else { const tx = coin.decodeTransactionFromPrebuild(params.txPrebuild); - const signedTx = await fixedScript.signTransaction(coin, tx, getSignerKeychain(params.prv), coin.name, { + const signedTx = await fixedScript.signTransaction(coin, tx, getSignerKeychain(params.prv), coin.wasmName, { walletId: params.txPrebuild.walletId, txInfo: params.txPrebuild.txInfo, isLastSignature: params.isLastSignature ?? false, diff --git a/modules/abstract-utxo/src/transaction/verifyTransaction.ts b/modules/abstract-utxo/src/transaction/verifyTransaction.ts index c44915d8fa..d4ea9da848 100644 --- a/modules/abstract-utxo/src/transaction/verifyTransaction.ts +++ b/modules/abstract-utxo/src/transaction/verifyTransaction.ts @@ -12,7 +12,7 @@ export async function verifyTransaction( coin: AbstractUtxoCoin, bitgo: BitGoBase, params: VerifyTransactionOptions, - addressCodec: AddressCodec = new AddressCodec(coin.name) + addressCodec: AddressCodec = coin.addressCodec ): Promise { if (isDescriptorWallet(params.wallet)) { const walletKeys = toBip32Triple(await fetchKeychains(coin, params.wallet)); diff --git a/modules/abstract-utxo/test/unit/descriptorAddress.ts b/modules/abstract-utxo/test/unit/descriptorAddress.ts index 402e34b23f..8a91cd39c4 100644 --- a/modules/abstract-utxo/test/unit/descriptorAddress.ts +++ b/modules/abstract-utxo/test/unit/descriptorAddress.ts @@ -5,14 +5,26 @@ import * as testutils from '@bitgo/wasm-utxo/testutils'; import { IWallet, WalletCoinSpecific } from '@bitgo/sdk-core'; import { descriptor as utxod } from '../../src'; +import { Tbtc } from '../../src/impl/btc'; +import type { WasmUtxoCoinName } from '../../src/names'; -import { getUtxoCoin } from './util'; +import { defaultBitGo, getUtxoCoin } from './util'; export function getDescriptorAddress(d: string, index: number, coinName: CoinName): string { const derivedScript = utxod.Descriptor.fromString(d, 'derivable').atDerivationIndex(index).scriptPubkey(); return wasmAddress.fromOutputScriptWithCoin(derivedScript, coinName); } +class RegtestTbtc extends Tbtc { + constructor() { + super(defaultBitGo); + } + + override get wasmName(): WasmUtxoCoinName { + return 'tbtcreg'; + } +} + describe('descriptor wallets', function () { const coin = getUtxoCoin('tbtc'); const xpubs = testutils.getKeyTriple('setec astronomy').map((k) => k.neutered().toBase58()); @@ -39,9 +51,9 @@ describe('descriptor wallets', function () { const descFoo = getNamedDescriptor2Of2('foo', xpubs[0], xpubs[1]); const descBar = getNamedDescriptor2Of2('bar', xpubs[1], xpubs[0]); - const addressFoo0 = getDescriptorAddress(descFoo.value, 0, coin.name); - const addressFoo1 = getDescriptorAddress(descFoo.value, 1, coin.name); - const addressBar0 = getDescriptorAddress(descBar.value, 0, coin.name); + const addressFoo0 = getDescriptorAddress(descFoo.value, 0, coin.wasmName); + const addressFoo1 = getDescriptorAddress(descFoo.value, 1, coin.wasmName); + const addressBar0 = getDescriptorAddress(descBar.value, 0, coin.wasmName); it('has expected values', function () { assert.deepStrictEqual( @@ -87,4 +99,23 @@ describe('descriptor wallets', function () { runTestIsAddress(addressFoo1, 0, 'foo', descFoo.value.slice(-8), /Address mismatch for descriptor/); runTestIsAddress(addressBar0, 0, 'bar', descFoo.value.slice(-8), /Descriptor checksum mismatch/); runTestIsAddress(addressFoo0, 0, 'bar', descBar.value.slice(-8), /Address mismatch for descriptor/); + + it('uses the coin address codec for descriptor wallet addresses', async function () { + const regtestCoin = new RegtestTbtc(); + const address = getDescriptorAddress(descFoo.value, 0, regtestCoin.wasmName); + const wallet = getIWalletWithDescriptors([descFoo, descBar]); + + assert.strictEqual( + await regtestCoin.isWalletAddress( + { + address, + index: 0, + coinSpecific: { descriptorName: 'foo', descriptorChecksum: descFoo.value.slice(-8) }, + keychains: xpubs.map((pub) => ({ pub })), + }, + wallet + ), + true + ); + }); }); diff --git a/modules/abstract-utxo/test/unit/impl/bch/unit/bch.ts b/modules/abstract-utxo/test/unit/impl/bch/unit/bch.ts index 28378a9239..ef12844da5 100644 --- a/modules/abstract-utxo/test/unit/impl/bch/unit/bch.ts +++ b/modules/abstract-utxo/test/unit/impl/bch/unit/bch.ts @@ -134,4 +134,14 @@ describe('Custom BCH Tests', function () { assert.throws(() => bch.canonicalAddress('bitcoincash:qqq3728yw0y47sqn6l2na30mcw6zm78dzqre909m2r', 'blah')); assert.throws(() => bch.canonicalAddress(undefined as any, 'blah')); }); + + it('should validate BCH address formats through format-specific codecs', function () { + const base58Address = '1BpEi6DfDAUFd7GtittLSdBeYJvcoaVggu'; + const cashaddr = 'bitcoincash:qpm2qsznhks23z7629mms6s4cwef74vcwvy22gdx6a'; + + assert.strictEqual(bch.isValidAddress(base58Address), true); + assert.strictEqual(bch.isValidAddress(cashaddr), true); + assert.strictEqual(bch.isValidAddress(cashaddr, { anyFormat: false }), false); + assert.strictEqual(bch.isValidAddress('bitcoincash:not-an-address'), false); + }); }); diff --git a/modules/abstract-utxo/test/unit/recovery/backupKeyRecovery.ts b/modules/abstract-utxo/test/unit/recovery/backupKeyRecovery.ts index e1806bbcb7..36573c4380 100644 --- a/modules/abstract-utxo/test/unit/recovery/backupKeyRecovery.ts +++ b/modules/abstract-utxo/test/unit/recovery/backupKeyRecovery.ts @@ -103,7 +103,7 @@ function run( }); const fixturePsbt = fixedScriptWallet.BitGoPsbt.fromBytes( Buffer.from(storedFixture.psbtHex, 'hex'), - fixtureCoin.name + fixtureCoin.wasmName ); fixtureParsed = fixturePsbt.parseTransactionWithWalletKeys(wasmWalletKeys, { replayProtection: { publicKeys: replayProtection }, diff --git a/modules/abstract-utxo/test/unit/recovery/crossChainRecovery.ts b/modules/abstract-utxo/test/unit/recovery/crossChainRecovery.ts index 9da3e0e230..2c7b6befbc 100644 --- a/modules/abstract-utxo/test/unit/recovery/crossChainRecovery.ts +++ b/modules/abstract-utxo/test/unit/recovery/crossChainRecovery.ts @@ -162,7 +162,7 @@ function run(sourceCoin: AbstractUtxoC function checkRecoveryPsbtSignature(psbtHex: string) { // Parse using wasm-utxo for signature verification - const wasmPsbt = fixedScriptWallet.BitGoPsbt.fromBytes(Buffer.from(psbtHex, 'hex'), sourceCoin.name); + const wasmPsbt = fixedScriptWallet.BitGoPsbt.fromBytes(Buffer.from(psbtHex, 'hex'), sourceCoin.wasmName); const parsed = wasmPsbt.parseTransactionWithWalletKeys(wasmWalletKeys, { replayProtection: { publicKeys: [] } }); const unspents = getRecoveryUnspents(); assert.strictEqual(parsed.inputs.length, unspents.length); diff --git a/modules/abstract-utxo/test/unit/recovery/formatBackupKeyRecoveryResult.ts b/modules/abstract-utxo/test/unit/recovery/formatBackupKeyRecoveryResult.ts index 1371667117..5ff2b23150 100644 --- a/modules/abstract-utxo/test/unit/recovery/formatBackupKeyRecoveryResult.ts +++ b/modules/abstract-utxo/test/unit/recovery/formatBackupKeyRecoveryResult.ts @@ -50,7 +50,7 @@ function createTestUnspents(): WalletUnspent[] { * Clone a PSBT - necessary because formatBackupKeyRecoveryResult mutates when finalizing. */ function clonePsbt(psbt: fixedScriptWallet.BitGoPsbt): fixedScriptWallet.BitGoPsbt { - return fixedScriptWallet.BitGoPsbt.fromBytes(psbt.serialize(), coin.name); + return fixedScriptWallet.BitGoPsbt.fromBytes(psbt.serialize(), coin.wasmName); } describe('formatBackupKeyRecoveryResult', function () { diff --git a/modules/abstract-utxo/test/unit/recovery/mock.ts b/modules/abstract-utxo/test/unit/recovery/mock.ts index 6c4e93ffbe..23617e1fad 100644 --- a/modules/abstract-utxo/test/unit/recovery/mock.ts +++ b/modules/abstract-utxo/test/unit/recovery/mock.ts @@ -71,11 +71,8 @@ export class WasmCrossChainRecoveryProvider imp // Format the deposit address for BCH-like coins let formattedAddress = this.depositUnspent.address; if (this.addressFormat === 'cashaddr') { - formattedAddress = wasmAddress.fromOutputScriptWithCoin( - wasmAddress.toOutputScriptWithCoin(this.depositUnspent.address, this.coin.name), - this.coin.name, - this.addressFormat - ); + const script = wasmAddress.toOutputScriptWithCoin(this.depositUnspent.address, this.coin.wasmName); + formattedAddress = wasmAddress.fromOutputScriptWithCoin(script, this.coin.wasmName, 'cashaddr'); if (formattedAddress.includes(':')) { [, formattedAddress] = formattedAddress.split(':'); } @@ -95,11 +92,8 @@ export class WasmCrossChainRecoveryProvider imp // Format deposit address for output let outputAddress = this.depositUnspent.address; if (this.addressFormat === 'cashaddr') { - outputAddress = wasmAddress.fromOutputScriptWithCoin( - wasmAddress.toOutputScriptWithCoin(this.depositUnspent.address, this.coin.name), - this.coin.name, - this.addressFormat - ); + const script = wasmAddress.toOutputScriptWithCoin(this.depositUnspent.address, this.coin.wasmName); + outputAddress = wasmAddress.fromOutputScriptWithCoin(script, this.coin.wasmName, 'cashaddr'); if (outputAddress.includes(':')) { [, outputAddress] = outputAddress.split(':'); } diff --git a/modules/abstract-utxo/test/unit/transaction.ts b/modules/abstract-utxo/test/unit/transaction.ts index 09f8d51201..2146e2c991 100644 --- a/modules/abstract-utxo/test/unit/transaction.ts +++ b/modules/abstract-utxo/test/unit/transaction.ts @@ -5,7 +5,7 @@ import * as _ from 'lodash'; import * as utxolib from '@bitgo/utxo-lib'; import nock = require('nock'); import { BIP32Interface, bitgo, testutil } from '@bitgo/utxo-lib'; -import { address as wasmAddress, fixedScriptWallet, BIP32 } from '@bitgo/wasm-utxo'; +import { fixedScriptWallet, BIP32 } from '@bitgo/wasm-utxo'; import { common, FullySignedTransaction, @@ -278,7 +278,7 @@ function run( const unspents = getUnspents(); const prevOutputs = unspents.map( (u): utxolib.TxOutput => ({ - script: Buffer.from(wasmAddress.toOutputScriptWithCoin(u.address, coin.name)), + script: Buffer.from(coin.addressCodec.decode(u.address)), value: u.value, }) ); diff --git a/modules/abstract-utxo/test/unit/transaction/addressCodec.ts b/modules/abstract-utxo/test/unit/transaction/addressCodec.ts new file mode 100644 index 0000000000..aa3e2a07eb --- /dev/null +++ b/modules/abstract-utxo/test/unit/transaction/addressCodec.ts @@ -0,0 +1,32 @@ +import assert from 'node:assert/strict'; + +import { AddressCodec } from '../../../src/transaction/recipient'; + +describe('AddressCodec', function () { + const witnessScript = Buffer.from(`0014${'11'.repeat(20)}`, 'hex'); + + it('round-trips standard testnet addresses and rejects malformed addresses', function () { + const codec = new AddressCodec('tbtc'); + const address = codec.encode(witnessScript); + + assert.match(address, /^tb1q/); + assert.deepStrictEqual(Buffer.from(codec.decode(address)), witnessScript); + assert.strictEqual(codec.isValidAddress(address), true); + assert.strictEqual(codec.isValidAddress(`${address.slice(0, -1)}0`), false); + }); + + it('supports a separate WASM codec name without changing the public coin name', function () { + const codec = new AddressCodec('tbtc', 'tbtcreg'); + const address = codec.encode(witnessScript); + + assert.match(address, /^bcrt1q/); + assert.deepStrictEqual(Buffer.from(codec.decode(address)), witnessScript); + assert.strictEqual(codec.isValidAddress(address), true); + }); + + it('does not treat cashaddr as a general address format', function () { + const codec = new AddressCodec('bch'); + + assert.strictEqual(codec.isValidAddress('bitcoincash:qpm2qsznhks23z7629mms6s4cwef74vcwvy22gdx6a'), false); + }); +}); diff --git a/modules/abstract-utxo/test/unit/util/address.ts b/modules/abstract-utxo/test/unit/util/address.ts index 21cabb1c4a..b7a499b3b5 100644 --- a/modules/abstract-utxo/test/unit/util/address.ts +++ b/modules/abstract-utxo/test/unit/util/address.ts @@ -1,6 +1,7 @@ import * as utxolib from '@bitgo/utxo-lib'; import { fixedScriptWallet, type CoinName } from '@bitgo/wasm-utxo'; +import { toWasmUtxoCoinName, type UtxoCoinName } from '../../../src/names'; const { ChainCode } = fixedScriptWallet; type UtxolibRootWalletKeys = utxolib.bitgo.RootWalletKeys; @@ -15,10 +16,10 @@ const defaultChain = ChainCode.value('p2sh', 'external'); * Utxolib keys are converted to wasm-utxo keys for address generation. */ export function getWalletAddress( - coinName: CoinName, + coinName: CoinName | UtxoCoinName, walletKeys: RootWalletKeys, chain = defaultChain, index = 0 ): string { - return fixedScriptWallet.address(walletKeys, chain, index, coinName); + return fixedScriptWallet.address(walletKeys, chain, index, toWasmUtxoCoinName(coinName)); } diff --git a/modules/abstract-utxo/test/unit/util/nockIndexerAPI.ts b/modules/abstract-utxo/test/unit/util/nockIndexerAPI.ts index f818b2f3cf..5e186214de 100644 --- a/modules/abstract-utxo/test/unit/util/nockIndexerAPI.ts +++ b/modules/abstract-utxo/test/unit/util/nockIndexerAPI.ts @@ -1,6 +1,5 @@ import nock = require('nock'); import * as utxolib from '@bitgo/utxo-lib'; -import { address as wasmAddress } from '@bitgo/wasm-utxo'; import { AbstractUtxoCoin } from '../../../src'; @@ -20,7 +19,7 @@ export function nockBitGoPublicTransaction ({ address: u.address })), - outputs: tx.outs.map((o) => ({ address: wasmAddress.fromOutputScriptWithCoin(o.script, coin.name) })), + outputs: tx.outs.map((o) => ({ address: coin.addressCodec.encode(o.script) })), }; return nockBitGo().get(`/api/v2/${coin.getChain()}/public/tx/${tx.getId()}`).reply(200, payload); } @@ -34,7 +33,7 @@ export function nockBitGoPublicAddressUnspents ({ id: `${txid}:${vout}`, - address: wasmAddress.fromOutputScriptWithCoin(o.script, coin.name), + address: coin.addressCodec.encode(o.script), value: Number(o.value), valueString: coin.amountType === 'bigint' ? o.value.toString() : undefined, }) diff --git a/modules/abstract-utxo/test/unit/util/transaction.ts b/modules/abstract-utxo/test/unit/util/transaction.ts index 1c8de5564d..b6df414a6a 100644 --- a/modules/abstract-utxo/test/unit/util/transaction.ts +++ b/modules/abstract-utxo/test/unit/util/transaction.ts @@ -3,7 +3,7 @@ import assert from 'assert'; import * as utxolib from '@bitgo/utxo-lib'; import { ECPair, fixedScriptWallet, hasPsbtMagic, address as wasmAddress } from '@bitgo/wasm-utxo'; -import type { UtxoCoinName } from '../../../src/names'; +import { toWasmUtxoCoinName, type UtxoCoinName } from '../../../src/names'; import type { Unspent } from '../../../src/unspent'; import { getCoinNameForNetwork } from './utxoCoins'; @@ -30,7 +30,9 @@ function toTxOutput( network: utxolib.Network ): utxolib.TxOutput { return { - script: Buffer.from(wasmAddress.toOutputScriptWithCoin(u.address, getCoinNameForNetwork(network))), + script: Buffer.from( + wasmAddress.toOutputScriptWithCoin(u.address, toWasmUtxoCoinName(getCoinNameForNetwork(network))) + ), value: u.value, }; } @@ -48,8 +50,8 @@ export function assertEqualParsedPsbt( if (!hasPsbtMagic(b)) { throw new Error('b is not a psbt'); } - const aPsbt = fixedScriptWallet.BitGoPsbt.fromBytes(a, coinName); - const bPsbt = fixedScriptWallet.BitGoPsbt.fromBytes(b, coinName); + const aPsbt = fixedScriptWallet.BitGoPsbt.fromBytes(a, toWasmUtxoCoinName(coinName)); + const bPsbt = fixedScriptWallet.BitGoPsbt.fromBytes(b, toWasmUtxoCoinName(coinName)); const aParsed = aPsbt.parseTransactionWithWalletKeys(walletKeys, { replayProtection: { publicKeys: replayProtection }, }); diff --git a/modules/abstract-utxo/test/unit/util/unspents.ts b/modules/abstract-utxo/test/unit/util/unspents.ts index ab8c85873e..06b1979328 100644 --- a/modules/abstract-utxo/test/unit/util/unspents.ts +++ b/modules/abstract-utxo/test/unit/util/unspents.ts @@ -3,7 +3,7 @@ import { getSeed } from '@bitgo/sdk-test'; import * as wasmUtxo from '@bitgo/wasm-utxo'; import { getReplayProtectionAddresses } from '../../../src'; -import { isUtxoCoinName, type UtxoCoinName } from '../../../src/names'; +import { isUtxoCoinName, toWasmUtxoCoinName, type UtxoCoinName } from '../../../src/names'; import type { Unspent, UnspentWithPrevTx, WalletUnspent } from '../../../src/unspent'; import { getCoinNameForNetwork } from './utxoCoins'; @@ -154,7 +154,7 @@ export function createWasmWalletUnspent { // Get output script from address using correct wasm-utxo function - const outputScript = wasmUtxo.address.toOutputScriptWithCoin(address, toCoinName(network)); + const outputScript = wasmUtxo.address.toOutputScriptWithCoin(address, toWasmUtxoCoinName(toCoinName(network))); // Create a mock transaction with output at vout=0 const { txid } = createMockPrevTx(0, outputScript, BigInt(value)); @@ -250,7 +250,12 @@ export function toUnspentWithPrevTx( const coinName = toCoinName(network); // Get the output script for the wallet address - const outputScript = wasmUtxo.fixedScriptWallet.outputScript(rootWalletKeys, chain, index, coinName); + const outputScript = wasmUtxo.fixedScriptWallet.outputScript( + rootWalletKeys, + chain, + index, + toWasmUtxoCoinName(coinName) + ); // Create mock prevTx with output at vout=0 const { prevTx, txid } = createMockPrevTx(0, outputScript, input.value);