From cc428fdf2f58fe70bbb215765fa6d2e64809a194 Mon Sep 17 00:00:00 2001 From: Otto Allmendinger Date: Fri, 16 Jan 2026 10:31:49 +0100 Subject: [PATCH] feat(abstract-utxo): use wasm-utxo for inscriptionBuilder Convert inscriptionBuilder to use @bitgo/wasm-utxo instead of utxo-lib for inscription-related functionality. Update interfaces to accommodate the new WASM-based implementation and add helper functions to handle the transition. Issue: BTC-2936 Co-authored-by: llm-git --- .../src/impl/btc/inscriptionBuilder.ts | 107 +++++-- modules/abstract-utxo/src/keychains.ts | 13 + modules/utxo-ord/package.json | 5 +- modules/utxo-ord/src/SatPoint.ts | 23 +- modules/utxo-ord/src/index.ts | 2 + modules/utxo-ord/src/inscriptions.ts | 287 +++++++----------- modules/utxo-ord/src/psbt.ts | 145 ++++++--- modules/utxo-ord/test/inscription.ts | 209 ++++++++----- modules/utxo-ord/test/psbt.ts | 82 ++--- modules/utxo-ord/test/testutils.ts | 57 ++++ 10 files changed, 575 insertions(+), 355 deletions(-) create mode 100644 modules/utxo-ord/test/testutils.ts diff --git a/modules/abstract-utxo/src/impl/btc/inscriptionBuilder.ts b/modules/abstract-utxo/src/impl/btc/inscriptionBuilder.ts index 21ab955f5f..f77ddb30e7 100644 --- a/modules/abstract-utxo/src/impl/btc/inscriptionBuilder.ts +++ b/modules/abstract-utxo/src/impl/btc/inscriptionBuilder.ts @@ -1,6 +1,7 @@ import assert from 'assert'; import { + BaseCoin, HalfSignedUtxoTransaction, IInscriptionBuilder, IWallet, @@ -9,9 +10,8 @@ import { PreparedInscriptionRevealData, SubmitTransactionResponse, xprvToRawPrv, - xpubToCompressedPub, } from '@bitgo/sdk-core'; -import * as utxolib from '@bitgo/utxo-lib'; +import { bip32 } from '@bitgo/secp256k1'; import { createPsbtForSingleInscriptionPassingTransaction, DefaultInscriptionConstraints, @@ -23,10 +23,24 @@ import { findOutputLayoutForWalletUnspents, MAX_UNSPENTS_FOR_OUTPUT_LAYOUT, SatPoint, + WalletUnspent, + type TapLeafScript, } from '@bitgo/utxo-ord'; +import { fixedScriptWallet } from '@bitgo/wasm-utxo'; -import { AbstractUtxoCoin, RootWalletKeys } from '../../abstractUtxoCoin'; -import { getWalletKeys } from '../../recovery/crossChainRecovery'; +import { AbstractUtxoCoin } from '../../abstractUtxoCoin'; +import { fetchWasmRootWalletKeys } from '../../keychains'; + +/** Key identifier for signing */ +type SignerKey = 'user' | 'backup' | 'bitgo'; + +/** Unspent from wallet API (value may be number or bigint) */ +type WalletUnspentLike = { + id: string; + value: number | bigint; + chain: number; + index: number; +}; const SUPPLEMENTARY_UNSPENTS_MIN_VALUE_SATS = [0, 20_000, 200_000]; @@ -43,11 +57,26 @@ export class InscriptionBuilder implements IInscriptionBuilder { const user = await this.wallet.baseCoin.keychains().get({ id: this.wallet.keyIds()[KeyIndices.USER] }); assert(user.pub); - const derived = this.coin.deriveKeyWithSeed({ key: user.pub, seed: inscriptionData.toString() }); - const compressedPublicKey = xpubToCompressedPub(derived.key); - const xOnlyPublicKey = utxolib.bitgo.outputScripts.toXOnlyPublicKey(Buffer.from(compressedPublicKey, 'hex')); + const userKey = bip32.fromBase58(user.pub); + const { key: derivedKey } = BaseCoin.deriveKeyWithSeedBip32(userKey, inscriptionData.toString()); + + const result = inscriptions.createInscriptionRevealData( + derivedKey.publicKey, + contentType, + inscriptionData, + this.coin.name + ); - return inscriptions.createInscriptionRevealData(xOnlyPublicKey, contentType, inscriptionData, this.coin.network); + // Convert TapLeafScript to utxolib format for backwards compatibility + return { + address: result.address, + revealTransactionVSize: result.revealTransactionVSize, + tapLeafScript: { + controlBlock: Buffer.from(result.tapLeafScript.controlBlock), + script: Buffer.from(result.tapLeafScript.script), + leafVersion: result.tapLeafScript.leafVersion, + }, + }; } private async prepareTransferWithExtraInputs( @@ -59,8 +88,8 @@ export class InscriptionBuilder implements IInscriptionBuilder { inscriptionConstraints, txFormat, }: { - signer: utxolib.bitgo.KeyName; - cosigner: utxolib.bitgo.KeyName; + signer: SignerKey; + cosigner: SignerKey; inscriptionConstraints: { minChangeOutput?: bigint; minInscriptionOutput?: bigint; @@ -68,27 +97,32 @@ export class InscriptionBuilder implements IInscriptionBuilder { }; txFormat?: 'psbt' | 'legacy'; }, - rootWalletKeys: RootWalletKeys, + rootWalletKeys: fixedScriptWallet.RootWalletKeys, outputs: InscriptionOutputs, - inscriptionUnspents: utxolib.bitgo.WalletUnspent[], + inscriptionUnspents: WalletUnspent[], supplementaryUnspentsMinValue: number ): Promise { - let supplementaryUnspents: utxolib.bitgo.WalletUnspent[] = []; + let supplementaryUnspents: WalletUnspent[] = []; if (supplementaryUnspentsMinValue > 0) { const response = await this.wallet.unspents({ minValue: supplementaryUnspentsMinValue, }); // Filter out the inscription unspent from the supplementary unspents supplementaryUnspents = response.unspents - .filter((unspent) => unspent.id !== inscriptionUnspents[0].id) + .filter((unspent: { id: string }) => unspent.id !== inscriptionUnspents[0].id) .slice(0, MAX_UNSPENTS_FOR_OUTPUT_LAYOUT - 1) - .map((unspent) => { - unspent.value = BigInt(unspent.value); - return unspent; - }); + .map( + (unspent: WalletUnspentLike): WalletUnspent => ({ + id: unspent.id, + value: BigInt(unspent.value), + chain: unspent.chain, + index: unspent.index, + }) + ); } + const psbt = createPsbtForSingleInscriptionPassingTransaction( - this.coin.network, + this.coin.name, { walletKeys: rootWalletKeys, signer, @@ -117,7 +151,7 @@ export class InscriptionBuilder implements IInscriptionBuilder { } return { walletId: this.wallet.id(), - txHex: txFormat === 'psbt' ? psbt.toHex() : psbt.getUnsignedTx().toHex(), + txHex: Buffer.from(psbt.serialize()).toString('hex'), txInfo: { unspents: allUnspents }, feeInfo: { fee: Number(outputLayout.layout.feeOutput), feeString: outputLayout.layout.feeOutput.toString() }, }; @@ -146,27 +180,36 @@ export class InscriptionBuilder implements IInscriptionBuilder { changeAddressType = 'p2wsh', txFormat = 'psbt', }: { - signer?: utxolib.bitgo.KeyName; - cosigner?: utxolib.bitgo.KeyName; + signer?: SignerKey; + cosigner?: SignerKey; inscriptionConstraints?: { minChangeOutput?: bigint; minInscriptionOutput?: bigint; maxInscriptionOutput?: bigint; }; - changeAddressType?: utxolib.bitgo.outputScripts.ScriptType2Of3; + changeAddressType?: 'p2sh' | 'p2shP2wsh' | 'p2wsh' | 'p2tr' | 'p2trMusig2'; txFormat?: 'psbt' | 'legacy'; } ): Promise { assert(isSatPoint(satPoint)); - const rootWalletKeys = await getWalletKeys(this.coin, this.wallet); + const rootWalletKeys = await fetchWasmRootWalletKeys(this.coin, this.wallet); const parsedSatPoint = parseSatPoint(satPoint); const transaction = await this.wallet.getTransaction({ txHash: parsedSatPoint.txid }); - const unspents: utxolib.bitgo.WalletUnspent[] = [transaction.outputs[parsedSatPoint.vout]]; - unspents[0].value = BigInt(unspents[0].value); + const output = transaction.outputs[parsedSatPoint.vout]; + const unspents: WalletUnspent[] = [ + { + id: `${parsedSatPoint.txid}:${parsedSatPoint.vout}`, + value: BigInt(output.value), + chain: output.chain, + index: output.index, + }, + ]; + + const changeChain = fixedScriptWallet.ChainCode.value(changeAddressType, 'internal'); const changeAddress = await this.wallet.createAddress({ - chain: utxolib.bitgo.getInternalChainCode(changeAddressType), + chain: changeChain, }); const outputs: InscriptionOutputs = { inscriptionRecipient: recipient, @@ -209,10 +252,10 @@ export class InscriptionBuilder implements IInscriptionBuilder { */ async signAndSendReveal( walletPassphrase: string, - tapLeafScript: utxolib.bitgo.TapLeafScript, + tapLeafScript: TapLeafScript, commitAddress: string, unsignedCommitTx: Buffer, - commitTransactionUnspents: utxolib.bitgo.WalletUnspent[], + commitTransactionUnspents: WalletUnspentLike[], recipientAddress: string, inscriptionData: Buffer ): Promise { @@ -230,19 +273,19 @@ export class InscriptionBuilder implements IInscriptionBuilder { const derived = this.coin.deriveKeyWithSeed({ key: xprv, seed: inscriptionData.toString() }); const prv = xprvToRawPrv(derived.key); - const fullySignedRevealTransaction = await inscriptions.signRevealTransaction( + const fullySignedRevealTransaction = inscriptions.signRevealTransaction( Buffer.from(prv, 'hex'), tapLeafScript, commitAddress, recipientAddress, Buffer.from(halfSignedCommitTransaction.txHex, 'hex'), - this.coin.network + this.coin.name ); return this.wallet.submitTransaction({ halfSigned: { txHex: halfSignedCommitTransaction.txHex, - signedChildPsbt: fullySignedRevealTransaction.toHex(), + signedChildPsbt: Buffer.from(fullySignedRevealTransaction).toString('hex'), }, }); } diff --git a/modules/abstract-utxo/src/keychains.ts b/modules/abstract-utxo/src/keychains.ts index 0fe91b9f01..b4b2cdccd0 100644 --- a/modules/abstract-utxo/src/keychains.ts +++ b/modules/abstract-utxo/src/keychains.ts @@ -4,6 +4,7 @@ import * as t from 'io-ts'; import { bitgo } from '@bitgo/utxo-lib'; import { BIP32Interface, bip32 } from '@bitgo/secp256k1'; import { IRequestTracer, IWallet, KeyIndices, promiseProps, Triple } from '@bitgo/sdk-core'; +import { fixedScriptWallet } from '@bitgo/wasm-utxo'; import { AbstractUtxoCoin } from './abstractUtxoCoin'; import { UtxoWallet } from './wallet'; @@ -108,6 +109,18 @@ export async function fetchKeychains( return result; } +/** + * Fetch wallet keys as wasm-utxo RootWalletKeys + */ +export async function fetchWasmRootWalletKeys( + coin: AbstractUtxoCoin, + wallet: IWallet, + reqId?: IRequestTracer +): Promise { + const keychains = await fetchKeychains(coin, wallet, reqId); + return fixedScriptWallet.RootWalletKeys.from([keychains.user.pub, keychains.backup.pub, keychains.bitgo.pub]); +} + export const KeySignatures = t.partial({ backupPub: t.string, bitgoPub: t.string, diff --git a/modules/utxo-ord/package.json b/modules/utxo-ord/package.json index 1651359816..bcbed6b75e 100644 --- a/modules/utxo-ord/package.json +++ b/modules/utxo-ord/package.json @@ -28,8 +28,9 @@ "directory": "modules/utxo-ord" }, "dependencies": { - "@bitgo/sdk-core": "^36.27.0", - "@bitgo/unspents": "^0.50.14", + "@bitgo/wasm-utxo": "^1.27.0" + }, + "devDependencies": { "@bitgo/utxo-lib": "^11.19.1" }, "lint-staged": { diff --git a/modules/utxo-ord/src/SatPoint.ts b/modules/utxo-ord/src/SatPoint.ts index 2141f782d1..0ddf3f06a9 100644 --- a/modules/utxo-ord/src/SatPoint.ts +++ b/modules/utxo-ord/src/SatPoint.ts @@ -9,10 +9,29 @@ https://github.com/casey/ord/blob/master/bip.mediawiki#terminology-and-notation > `680df1e4d43016571e504b0b142ee43c5c0b83398a97bdcfd94ea6f287322d22:0:6` */ -import { bitgo } from '@bitgo/utxo-lib'; export type SatPoint = `${string}:${number}:${bigint}`; +/** + * Parse an output ID (txid:vout) into its components. + */ +export function parseOutputId(outputId: string): { txid: string; vout: number } { + const colonIndex = outputId.lastIndexOf(':'); + if (colonIndex === -1) { + throw new Error(`Invalid output id format: missing colon`); + } + const txid = outputId.slice(0, colonIndex); + const voutStr = outputId.slice(colonIndex + 1); + if (txid.length !== 64 || !/^[0-9a-fA-F]+$/.test(txid)) { + throw new Error(`Invalid txid: must be 64 hex characters`); + } + const vout = parseInt(voutStr, 10); + if (isNaN(vout) || vout < 0) { + throw new Error(`Invalid vout: must be non-negative integer`); + } + return { txid, vout }; +} + export function parseSatPoint(p: SatPoint): { txid: string; vout: number; offset: bigint } { const parts = p.split(':'); if (parts.length !== 3) { @@ -27,7 +46,7 @@ export function parseSatPoint(p: SatPoint): { txid: string; vout: number; offset throw new Error(`SatPoint offset must be positive`); } return { - ...bitgo.parseOutputId([txid, vout].join(':')), + ...parseOutputId([txid, vout].join(':')), offset, }; } diff --git a/modules/utxo-ord/src/index.ts b/modules/utxo-ord/src/index.ts index 6f94feb91b..69a998addf 100644 --- a/modules/utxo-ord/src/index.ts +++ b/modules/utxo-ord/src/index.ts @@ -8,3 +8,5 @@ export * from './OutputLayout'; export * from './SatPoint'; export * from './psbt'; export * as inscriptions from './inscriptions'; +export type { TapLeafScript, PreparedInscriptionRevealData } from './inscriptions'; +export type { WalletUnspent } from './psbt'; diff --git a/modules/utxo-ord/src/inscriptions.ts b/modules/utxo-ord/src/inscriptions.ts index 7e8f92c31f..b6c3f2285d 100644 --- a/modules/utxo-ord/src/inscriptions.ts +++ b/modules/utxo-ord/src/inscriptions.ts @@ -1,219 +1,156 @@ /* Functions for dealing with inscriptions. +Wrapper around @bitgo/wasm-utxo inscription functions for utxo-ord consumers. + See https://docs.ordinals.com/inscriptions.html */ -import * as assert from 'assert'; import { - p2trPayments as payments, - ecc as eccLib, - script as bscript, - Payment, - Network, - bitgo, - address, - taproot, + inscriptions as wasmInscriptions, + address as wasmAddress, + Transaction, ECPair, -} from '@bitgo/utxo-lib'; -import * as utxolib from '@bitgo/utxo-lib'; -import { PreparedInscriptionRevealData } from '@bitgo/sdk-core'; + type TapLeafScript, + type CoinName, +} from '@bitgo/wasm-utxo'; + +export type { TapLeafScript }; -const OPS = bscript.OPS; -const MAX_LENGTH_TAP_DATA_PUSH = 520; // default "postage" amount // https://github.com/ordinals/ord/blob/0.24.2/src/lib.rs#L149 -const DEFAULT_POSTAGE_AMOUNT = BigInt(10_000); +const DEFAULT_POSTAGE_SATS = BigInt(10_000); /** - * The max size of an individual OP_PUSH in a Taproot script is 520 bytes. This - * function splits inscriptionData into an array buffer of 520 bytes length. - * https://docs.ordinals.com/inscriptions.html - * @param inscriptionData - * @param chunkSize + * Prepared data for an inscription reveal transaction. + * Compatible with sdk-core's PreparedInscriptionRevealData. */ -function splitBuffer(inscriptionData: Buffer, chunkSize: number) { - const pushDataBuffers: Buffer[] = []; - for (let i = 0; i < inscriptionData.length; i += chunkSize) { - pushDataBuffers.push(inscriptionData.slice(i, i + chunkSize)); - } +export type PreparedInscriptionRevealData = { + /** The commit address (derived from outputScript for the given network) */ + address: string; + /** Estimated virtual size of the reveal transaction */ + revealTransactionVSize: number; + /** Tap leaf script for spending the commit output */ + tapLeafScript: TapLeafScript; +}; + +/** + * BIP32-like interface compatible with both utxo-lib and wasm-utxo BIP32 types. + * The publicKey can be Buffer (utxo-lib) or Uint8Array (wasm-utxo). + */ +export interface BIP32Like { + publicKey: Uint8Array; +} + +/** Input type for inscription functions - either a BIP32-like key or raw public key bytes */ +export type KeyInput = BIP32Like | Uint8Array; - return pushDataBuffers; +function isBIP32Like(key: KeyInput): key is BIP32Like { + return typeof key === 'object' && key !== null && 'publicKey' in key && !ArrayBuffer.isView(key); } /** - * - * @returns inscription payment object - * @param pubkey - * @param contentType - * @param inscriptionData + * Extract compressed public key from key input. + * Handles BIP32-like objects and raw pubkey bytes (32 or 33 bytes). */ -function createPaymentForInscription(pubkey: Buffer, contentType: string, inscriptionData: Buffer): Payment { - const dataPushBuffers = splitBuffer(inscriptionData, MAX_LENGTH_TAP_DATA_PUSH); - - const uncompiledScript = [ - pubkey, - OPS.OP_CHECKSIG, - OPS.OP_FALSE, - OPS.OP_IF, - Buffer.from('ord', 'ascii'), - 1, // these two lines should be combined as a single OPS.OP_1, - 1, // but `ord`'s decoder has a bug so it has to be like this - Buffer.from(contentType, 'ascii'), - OPS.OP_0, - ...dataPushBuffers, - OPS.OP_ENDIF, - ]; - - const compiledScript = bscript.compile(uncompiledScript); - const redeem: Payment = { - output: compiledScript, - depth: 0, - }; +function getCompressedPublicKey(key: KeyInput): Uint8Array { + const pubkey = isBIP32Like(key) ? key.publicKey : key; - return payments.p2tr({ redeems: [redeem], redeemIndex: 0 }, { eccLib }); + if (pubkey.length === 33) { + return pubkey; + } + if (pubkey.length === 32) { + // x-only pubkey - prepend 0x02 parity byte to make compressed + const compressedPubkey = new Uint8Array(33); + compressedPubkey[0] = 0x02; + compressedPubkey.set(pubkey, 1); + return compressedPubkey; + } + throw new Error(`Invalid public key length: ${pubkey.length}. Expected 32 or 33 bytes.`); } /** - * @param payment - * @param controlBlock - * @param commitOutput - * @param network - * @return virtual size of a transaction with a single inscription reveal input and a single commitOutput + * Create the P2TR output script for an inscription. + * + * @param key - BIP32 key or public key bytes (32-byte x-only or 33-byte compressed) + * @param contentType - MIME type of the inscription (e.g., "text/plain", "image/png") + * @param inscriptionData - The inscription data bytes + * @returns The P2TR output script for the inscription commit address */ -function getInscriptionRevealSize( - payment: Payment, - controlBlock: Buffer, - commitOutput: Buffer, - network: Network -): number { - const psbt = bitgo.createPsbtForNetwork({ network }); - const parsedControlBlock = taproot.parseControlBlock(eccLib, controlBlock); - const leafHash = taproot.getTapleafHash(eccLib, parsedControlBlock, payment.redeem?.output as Buffer); - - psbt.addInput({ - hash: Buffer.alloc(32), - index: 0, - witnessUtxo: { script: commitOutput, value: BigInt(100_000) }, - tapLeafScript: [ - { - controlBlock, - script: payment.redeem?.output as Buffer, - leafVersion: taproot.INITIAL_TAPSCRIPT_VERSION, - }, - ], - }); - psbt.addOutput({ script: commitOutput, value: DEFAULT_POSTAGE_AMOUNT }); - - psbt.signTaprootInput( - 0, - { - publicKey: Buffer.alloc(32), - signSchnorr(hash: Buffer): Buffer { - // dummy schnorr-sized signature - return Buffer.alloc(64); - }, - }, - [leafHash] - ); - - psbt.finalizeTapInputWithSingleLeafScriptAndSignature(0); - return psbt.extractTransaction(/* disableFeeCheck */ true).virtualSize(); +export function createOutputScriptForInscription( + key: KeyInput, + contentType: string, + inscriptionData: Uint8Array +): Uint8Array { + const compressedPubkey = getCompressedPublicKey(key); + const ecpair = ECPair.fromPublicKey(compressedPubkey); + const result = wasmInscriptions.createInscriptionRevealData(ecpair, contentType, inscriptionData); + return result.outputScript; } /** - * @param pubkey - * @param contentType - * @param inscriptionData - * @param network - * @returns PreparedInscriptionRevealData + * Create inscription reveal data including the commit address and tap leaf script. + * + * @param key - BIP32 key or public key bytes (32-byte x-only or 33-byte compressed) + * @param contentType - MIME type of the inscription (e.g., "text/plain", "image/png") + * @param inscriptionData - The inscription data bytes + * @param coinName - Coin name (e.g., "btc", "tbtc") + * @returns PreparedInscriptionRevealData with address, vsize estimate, and tap leaf script */ export function createInscriptionRevealData( - pubkey: Buffer, + key: KeyInput, contentType: string, - inscriptionData: Buffer, - network: Network + inscriptionData: Uint8Array, + coinName: CoinName ): PreparedInscriptionRevealData { - const payment = createPaymentForInscription(pubkey, contentType, inscriptionData); - - const { output: commitOutput, controlBlock } = payment; - assert.ok(commitOutput); - assert.ok(controlBlock); - assert.ok(payment.redeem?.output); - const commitAddress = address.fromOutputScript(commitOutput, network); - - const tapLeafScript: utxolib.bitgo.TapLeafScript[] = [ - { - controlBlock, - script: payment.redeem?.output, - leafVersion: taproot.INITIAL_TAPSCRIPT_VERSION, - }, - ]; - const revealTransactionVSize = getInscriptionRevealSize(payment, controlBlock, commitOutput, network); + const compressedPubkey = getCompressedPublicKey(key); + const ecpair = ECPair.fromPublicKey(compressedPubkey); - return { - address: commitAddress, - revealTransactionVSize, - tapLeafScript: tapLeafScript[0], - }; -} + const wasmResult = wasmInscriptions.createInscriptionRevealData(ecpair, contentType, inscriptionData); -/** - * @param pubkey - * @param contentType - * @param inscriptionData - * @returns inscription address - */ -export function createOutputScriptForInscription(pubkey: Buffer, contentType: string, inscriptionData: Buffer): Buffer { - const payment = createPaymentForInscription(pubkey, contentType, inscriptionData); + // Convert outputScript to address for the given network + const address = wasmAddress.fromOutputScriptWithCoin(wasmResult.outputScript, coinName); - assert.ok(payment.output, 'Failed to create inscription output script'); - return payment.output; + return { + address, + revealTransactionVSize: wasmResult.revealTransactionVSize, + tapLeafScript: wasmResult.tapLeafScript, + }; } /** + * Sign a reveal transaction. * - * @param privateKey - * @param tapLeafScript - * @param commitAddress - * @param recipientAddress - * @param unsignedCommitTx - * @param network + * Creates and signs the reveal transaction that spends from the commit output + * and sends the inscription to the recipient. * - * @return a fully signed reveal transaction + * @param privateKey - 32-byte private key + * @param tapLeafScript - The tap leaf script from createInscriptionRevealData + * @param commitAddress - The commit address + * @param recipientAddress - Where to send the inscription + * @param unsignedCommitTx - The unsigned commit transaction bytes + * @param coinName - Coin name (e.g., "btc", "tbtc") + * @returns The signed PSBT as bytes */ export function signRevealTransaction( - privateKey: Buffer, - tapLeafScript: utxolib.bitgo.TapLeafScript, + privateKey: Uint8Array, + tapLeafScript: TapLeafScript, commitAddress: string, recipientAddress: string, - unsignedCommitTx: Buffer, - network: Network -): utxolib.bitgo.UtxoPsbt { - const unserCommitTxn = utxolib.bitgo.createTransactionFromBuffer(unsignedCommitTx, network); - const hash = unserCommitTxn.getHash(); - const commitOutput = utxolib.address.toOutputScript(commitAddress, network); - const vout = unserCommitTxn.outs.findIndex((out) => out.script.equals(commitOutput)); - - if (vout === -1) { - throw new Error('Invalid commit transaction'); - } - - const psbt = bitgo.createPsbtForNetwork({ network }); - psbt.addInput({ - hash, - index: vout, - witnessUtxo: { script: commitOutput, value: BigInt(unserCommitTxn.outs[vout].value) }, - tapLeafScript: [tapLeafScript], - }); - - const recipientOutput = address.toOutputScript(recipientAddress, network); - psbt.addOutput({ script: recipientOutput, value: BigInt(10_000) }); - - const signer = ECPair.fromPrivateKey(privateKey); - const parsedControlBlock = taproot.parseControlBlock(eccLib, tapLeafScript.controlBlock); - const leafHash = taproot.getTapleafHash(eccLib, parsedControlBlock, tapLeafScript.script as Buffer); - psbt.signTaprootInput(0, signer, [leafHash]); - - return psbt; + unsignedCommitTx: Uint8Array, + coinName: CoinName +): Uint8Array { + const ecpair = ECPair.fromPrivateKey(privateKey); + const commitTx = Transaction.fromBytes(unsignedCommitTx); + const commitOutputScript = wasmAddress.toOutputScriptWithCoin(commitAddress, coinName); + const recipientOutputScript = wasmAddress.toOutputScriptWithCoin(recipientAddress, coinName); + + return wasmInscriptions.signRevealTransaction( + ecpair, + tapLeafScript, + commitTx, + commitOutputScript, + recipientOutputScript, + DEFAULT_POSTAGE_SATS + ); } diff --git a/modules/utxo-ord/src/psbt.ts b/modules/utxo-ord/src/psbt.ts index 33f0c311bf..7a5696ab3f 100644 --- a/modules/utxo-ord/src/psbt.ts +++ b/modules/utxo-ord/src/psbt.ts @@ -1,30 +1,77 @@ -import { Network, bitgo, address } from '@bitgo/utxo-lib'; -import { Dimensions, VirtualSizes } from '@bitgo/unspents'; +import { fixedScriptWallet, Dimensions, type CoinName } from '@bitgo/wasm-utxo'; import { OrdOutput } from './OrdOutput'; -import { parseSatPoint, SatPoint } from './SatPoint'; +import { parseSatPoint, parseOutputId, SatPoint } from './SatPoint'; import { SatRange } from './SatRange'; import { getOrdOutputsForLayout, OutputLayout, toArray, findOutputLayout } from './OutputLayout'; import { powerset } from './combinations'; -type WalletUnspent = bitgo.WalletUnspent; +type RootWalletKeys = fixedScriptWallet.RootWalletKeys; +type SignPath = fixedScriptWallet.SignPath; +const { BitGoPsbt, ChainCode } = fixedScriptWallet; + +/** + * Network type from utxo-lib for backward compatibility. + */ +type UtxolibNetwork = { + messagePrefix?: string; + bech32?: string; + pubKeyHash: number; + scriptHash: number; + wif: number; +}; + +/** + * Map utxo-lib network objects to CoinName strings. + */ +function networkToCoinName(network: UtxolibNetwork): CoinName { + // Bitcoin mainnet + if (network.bech32 === 'bc' && network.pubKeyHash === 0) { + return 'btc'; + } + // Bitcoin testnet + if (network.bech32 === 'tb' && network.pubKeyHash === 111) { + return 'tbtc'; + } + throw new Error(`Unknown network: ${JSON.stringify(network)}`); +} + +/** + * Normalize network parameter - accepts either CoinName string or utxo-lib Network object. + */ +function normalizeCoinName(networkOrCoinName: CoinName | UtxolibNetwork): CoinName { + if (typeof networkOrCoinName === 'string') { + return networkOrCoinName; + } + return networkToCoinName(networkOrCoinName); +} + +/** Segwit transaction overhead in virtual bytes */ +const TX_SEGWIT_OVERHEAD_VSIZE = 10; + +export type WalletUnspent = { + id: string; // "txid:vout" + value: bigint; + chain: number; + index: number; +}; export type WalletOutputPath = { - chain: bitgo.ChainCode; + chain: fixedScriptWallet.ChainCode; index: number; }; export type WalletInputBuilder = { - walletKeys: bitgo.RootWalletKeys; - signer: bitgo.KeyName; - cosigner: bitgo.KeyName; + walletKeys: RootWalletKeys; + signer: SignPath['signer']; + cosigner: SignPath['cosigner']; }; /** * Describes all outputs of an inscription transaction */ export type InscriptionTransactionOutputs = { - inscriptionRecipient: string | Buffer; + inscriptionRecipient: string | Uint8Array; changeOutputs: [WalletOutputPath, WalletOutputPath]; }; @@ -45,50 +92,61 @@ export const DefaultInscriptionConstraints = { }; export function createPsbtFromOutputLayout( - network: Network, + networkOrCoinName: CoinName | UtxolibNetwork, inputBuilder: WalletInputBuilder, unspents: WalletUnspent[], outputs: InscriptionTransactionOutputs, outputLayout: OutputLayout -): bitgo.UtxoPsbt { - const psbt = bitgo.createPsbtForNetwork({ network: network }); +): fixedScriptWallet.BitGoPsbt { if (unspents.length === 0) { throw new Error(`must provide at least one unspent`); } - unspents.forEach((u) => - bitgo.addWalletUnspentToPsbt(psbt, u, inputBuilder.walletKeys, inputBuilder.signer, inputBuilder.cosigner) - ); + + const coinName = normalizeCoinName(networkOrCoinName); + const psbt = BitGoPsbt.createEmpty(coinName, inputBuilder.walletKeys); + + // Add inputs + unspents.forEach((u) => { + const { txid, vout } = parseOutputId(u.id); + psbt.addWalletInput({ txid, vout, value: u.value }, inputBuilder.walletKeys, { + scriptId: { chain: u.chain, index: u.index }, + signPath: { signer: inputBuilder.signer, cosigner: inputBuilder.cosigner }, + }); + }); + + // Build ord outputs from layout const ordInput = OrdOutput.joinAll(unspents.map((u) => new OrdOutput(u.value))); const ordOutputs = getOrdOutputsForLayout(ordInput, outputLayout); + toArray(ordOutputs).forEach((ordOutput) => { if (ordOutput === null) { return; } switch (ordOutput) { - // skip padding outputs and fee output (virtual) - case null: + // skip fee output (virtual) case ordOutputs.feeOutput: return; - // add padding outputs + // add padding/change outputs case ordOutputs.firstChangeOutput: case ordOutputs.secondChangeOutput: const { chain, index } = ordOutput === ordOutputs.firstChangeOutput ? outputs.changeOutputs[0] : outputs.changeOutputs[1]; - bitgo.addWalletOutputToPsbt(psbt, inputBuilder.walletKeys, chain, index, ordOutput.value); + psbt.addWalletOutput(inputBuilder.walletKeys, { chain, index, value: ordOutput.value }); break; // add actual inscription output case ordOutputs.inscriptionOutput: - let { inscriptionRecipient } = outputs; - if (typeof inscriptionRecipient === 'string') { - inscriptionRecipient = address.toOutputScript(inscriptionRecipient, network); + const recipient = outputs.inscriptionRecipient; + if (typeof recipient === 'string') { + psbt.addOutput(recipient, ordOutput.value); + } else if (recipient instanceof Uint8Array) { + psbt.addOutput(recipient, ordOutput.value); + } else { + throw new Error('inscriptionRecipient must be a string or Uint8Array'); } - psbt.addOutput({ - script: inscriptionRecipient, - value: ordOutput.value, - }); break; } }); + return psbt; } @@ -134,6 +192,19 @@ export function findOutputLayoutForWalletUnspents( maxInscriptionOutput = DefaultInscriptionConstraints.maxInscriptionOutput, } = constraints; + // Calculate input vsize using wasm-utxo Dimensions + const inputDimensions = inputs.reduce( + (dims, input) => dims.plus(Dimensions.fromInput({ chain: input.chain })), + Dimensions.empty() + ); + const inputsVSize = inputDimensions.getInputVSize(); + + // Calculate output vsize using wasm-utxo Dimensions + const outputDimensions = Dimensions.fromOutput({ + scriptType: ChainCode.scriptType(outputs.changeOutputs[0].chain), + }); + const outputVSize = outputDimensions.getOutputVSize(); + // Join all the inputs into a single inscriptionOutput. // For the purposes of finding a layout there is no difference. const inscriptionOutput = OrdOutput.joinAll( @@ -143,18 +214,8 @@ export function findOutputLayoutForWalletUnspents( minChangeOutput, minInscriptionOutput, maxInscriptionOutput, - feeFixed: getFee( - VirtualSizes.txSegOverheadVSize + - Dimensions.fromUnspents(inputs, { - p2tr: { scriptPathLevel: 1 }, - p2trMusig2: { scriptPathLevel: undefined }, - }).getInputsVSize(), - constraints.feeRateSatKB - ), - feePerOutput: getFee( - Dimensions.fromOutputOnChain(outputs.changeOutputs[0].chain).getOutputsVSize(), - constraints.feeRateSatKB - ), + feeFixed: getFee(TX_SEGWIT_OVERHEAD_VSIZE + inputsVSize, constraints.feeRateSatKB), + feePerOutput: getFee(outputVSize, constraints.feeRateSatKB), }); return layout ? { inputs, layout } : undefined; @@ -199,7 +260,7 @@ export class ErrorNoLayout extends Error { } /** - * @param network + * @param networkOrCoinName - Coin name (e.g., "btc", "tbtc") or utxo-lib Network object * @param inputBuilder * @param unspent * @param satPoint @@ -209,7 +270,7 @@ export class ErrorNoLayout extends Error { * @param [minimizeInputs=true] - try to find input combination with minimal fees. Limits supplementaryUnspents to 4. */ export function createPsbtForSingleInscriptionPassingTransaction( - network: Network, + networkOrCoinName: CoinName | UtxolibNetwork, inputBuilder: WalletInputBuilder, unspent: WalletUnspent | WalletUnspent[], satPoint: SatPoint, @@ -222,7 +283,7 @@ export function createPsbtForSingleInscriptionPassingTransaction( supplementaryUnspents?: WalletUnspent[]; minimizeInputs?: boolean; } = {} -): bitgo.UtxoPsbt { +): fixedScriptWallet.BitGoPsbt { // support for legacy call style if (Array.isArray(unspent)) { if (unspent.length !== 1) { @@ -243,5 +304,5 @@ export function createPsbtForSingleInscriptionPassingTransaction( throw new ErrorNoLayout(); } - return createPsbtFromOutputLayout(network, inputBuilder, result.inputs, outputs, result.layout); + return createPsbtFromOutputLayout(networkOrCoinName, inputBuilder, result.inputs, outputs, result.layout); } diff --git a/modules/utxo-ord/test/inscription.ts b/modules/utxo-ord/test/inscription.ts index f6ab938449..8cf1f64ea4 100644 --- a/modules/utxo-ord/test/inscription.ts +++ b/modules/utxo-ord/test/inscription.ts @@ -1,46 +1,45 @@ -import * as utxolib from '@bitgo/utxo-lib'; import * as assert from 'assert'; -import { inscriptions, WalletInputBuilder } from '../src'; -import { address, networks, testutil, bitgo } from '@bitgo/utxo-lib'; - -function createCommitTransactionPsbt(commitAddress: string, walletKeys: utxolib.bitgo.RootWalletKeys) { - const commitTransactionOutputScript = utxolib.address.toOutputScript(commitAddress, networks.testnet); - const commitTransactionPsbt = utxolib.bitgo.createPsbtForNetwork({ network: networks.testnet }); +import { inscriptions } from '../src'; +import { address, networks } from '@bitgo/utxo-lib'; +import { address as wasmAddress, Psbt, ECPair, type CoinName } from '@bitgo/wasm-utxo'; + +const coinName: CoinName = 'tbtc'; + +/** + * Create a mock commit transaction with a P2TR output. + */ +function createMockCommitTx(commitOutputScript: Uint8Array): Uint8Array { + const psbt = new Psbt(); + + // Add a dummy input + psbt.addInput( + '0'.repeat(64), // dummy txid (32 zero bytes as hex) + 0, + BigInt(100_000), + commitOutputScript + ); - commitTransactionPsbt.addOutput({ - script: commitTransactionOutputScript, - value: BigInt(10_000), - }); + // Add the commit output + psbt.addOutput(commitOutputScript, BigInt(10_000)); - const walletUnspent = testutil.mockWalletUnspent(networks.testnet, BigInt(20_000), { keys: walletKeys }); - const inputBuilder: WalletInputBuilder = { - walletKeys, - signer: 'user', - cosigner: 'bitgo', - }; - - [walletUnspent].forEach((u) => - bitgo.addWalletUnspentToPsbt( - commitTransactionPsbt, - u, - inputBuilder.walletKeys, - inputBuilder.signer, - inputBuilder.cosigner - ) - ); - return commitTransactionPsbt; + return psbt.getUnsignedTx(); } describe('inscriptions', () => { const contentType = 'text/plain'; - const pubKey = 'af455f4989d122e9185f8c351dbaecd13adca3eef8a9d38ef8ffed6867e342e3'; - const pubKeyBuffer = Buffer.from(pubKey, 'hex'); + const xOnlyPubkeyHex = 'af455f4989d122e9185f8c351dbaecd13adca3eef8a9d38ef8ffed6867e342e3'; + const pubKeyBuffer = Buffer.from(xOnlyPubkeyHex, 'hex'); + + // Test keypair for signing - derive public key from private key + const testPrivateKey = Buffer.from('0000000000000000000000000000000000000000000000000000000000000001', 'hex'); + const testKeypair = ECPair.fromPrivateKey(testPrivateKey); + const testPublicKey = testKeypair.publicKey; describe('Inscription Output Script', () => { function testInscriptionScript(inscriptionData: Buffer, expectedScriptHex: string, expectedAddress: string) { const outputScript = inscriptions.createOutputScriptForInscription(pubKeyBuffer, contentType, inscriptionData); - assert.strictEqual(outputScript.toString('hex'), expectedScriptHex); - assert.strictEqual(address.fromOutputScript(outputScript, networks.testnet), expectedAddress); + assert.strictEqual(Buffer.from(outputScript).toString('hex'), expectedScriptHex); + assert.strictEqual(address.fromOutputScript(Buffer.from(outputScript), networks.testnet), expectedAddress); } it('should generate an inscription address', () => { @@ -65,59 +64,133 @@ describe('inscriptions', () => { }); describe('Inscription Reveal Data', () => { - it('should sign reveal transaction and validate reveal size', () => { - const walletKeys = testutil.getDefaultWalletKeys(); + it('should return valid tap leaf script data', () => { const inscriptionData = Buffer.from('And Desert You', 'ascii'); - const { revealTransactionVSize, tapLeafScript, address } = inscriptions.createInscriptionRevealData( - walletKeys.user.publicKey, + const revealData = inscriptions.createInscriptionRevealData( + testPublicKey, contentType, inscriptionData, - networks.testnet + coinName ); - const commitTransactionPsbt = createCommitTransactionPsbt(address, walletKeys); - // Use the commit address (P2TR) as recipient to match the output script size - // used in getInscriptionRevealSize estimation - const fullySignedRevealTransaction = inscriptions.signRevealTransaction( - walletKeys.user.privateKey as Buffer, - tapLeafScript, - address, - address, - commitTransactionPsbt.getUnsignedTx().toBuffer(), - networks.testnet + // Validate tap leaf script structure + assert.ok(revealData.tapLeafScript); + assert.strictEqual(revealData.tapLeafScript.leafVersion, 0xc0); // TapScript + assert.ok(revealData.tapLeafScript.script instanceof Uint8Array); + assert.ok(revealData.tapLeafScript.script.length > 0); + assert.ok(revealData.tapLeafScript.controlBlock instanceof Uint8Array); + assert.ok(revealData.tapLeafScript.controlBlock.length > 0); + }); + + it('should return a reasonable vsize estimate', () => { + const inscriptionData = Buffer.from('And Desert You', 'ascii'); + const revealData = inscriptions.createInscriptionRevealData( + testPublicKey, + contentType, + inscriptionData, + coinName ); - fullySignedRevealTransaction.finalizeTapInputWithSingleLeafScriptAndSignature(0); - const actualVirtualSize = fullySignedRevealTransaction.extractTransaction(true).virtualSize(); + // vsize should be reasonable (at least 100 vbytes for a simple inscription) + assert.ok(revealData.revealTransactionVSize > 100); + // But not too large for small data + assert.ok(revealData.revealTransactionVSize < 500); + }); + + it('should return address starting with tb1p for testnet', () => { + const inscriptionData = Buffer.from('And Desert You', 'ascii'); + const revealData = inscriptions.createInscriptionRevealData( + testPublicKey, + contentType, + inscriptionData, + coinName + ); - assert.strictEqual(revealTransactionVSize, actualVirtualSize); + // Taproot address for testnet + assert.ok(revealData.address.startsWith('tb1p')); }); }); - describe('Inscription Reveal Signature Validation', () => { - it('should not throw when validating reveal signatures and finalizing all reveal inputs', () => { - const walletKeys = testutil.getDefaultWalletKeys(); + describe('signRevealTransaction', () => { + it('should sign a reveal transaction', () => { const inscriptionData = Buffer.from('And Desert You', 'ascii'); - const { tapLeafScript, address } = inscriptions.createInscriptionRevealData( - walletKeys.user.publicKey, + const revealData = inscriptions.createInscriptionRevealData( + testPublicKey, contentType, inscriptionData, - networks.testnet + coinName + ); + + // Create commit output script + const commitOutputScript = wasmAddress.toOutputScriptWithCoin(revealData.address, coinName); + + // Create mock commit transaction + const commitTxBytes = createMockCommitTx(commitOutputScript); + + // Create recipient output script (P2WPKH) + const recipientOutputScript = Buffer.alloc(22); + recipientOutputScript[0] = 0x00; // OP_0 + recipientOutputScript[1] = 0x14; // PUSH20 + const recipientAddress = wasmAddress.fromOutputScriptWithCoin(recipientOutputScript, coinName); + + // Sign the reveal transaction + const txBytes = inscriptions.signRevealTransaction( + testPrivateKey, + revealData.tapLeafScript, + revealData.address, + recipientAddress, + commitTxBytes, + coinName ); - const commitTransactionPsbt = createCommitTransactionPsbt(address, walletKeys); - const fullySignedRevealTransaction = inscriptions.signRevealTransaction( - walletKeys.user.privateKey as Buffer, - tapLeafScript, - address, - '2N9R3mMCv6UfVbWEUW3eXJgxDeg4SCUVsu9', - commitTransactionPsbt.getUnsignedTx().toBuffer(), - networks.testnet + // Signed transaction should be non-empty + assert.ok(txBytes instanceof Uint8Array); + assert.ok(txBytes.length > 0); + + // Segwit transaction marker/flag: version(4) + marker(0x00) + flag(0x01) + // Version should be 2 (little-endian) + assert.strictEqual(txBytes[0], 0x02); + assert.strictEqual(txBytes[1], 0x00); + assert.strictEqual(txBytes[2], 0x00); + assert.strictEqual(txBytes[3], 0x00); + // Segwit marker and flag + assert.strictEqual(txBytes[4], 0x00); // marker + assert.strictEqual(txBytes[5], 0x01); // flag + }); + + it('should fail when commit output not found', () => { + const inscriptionData = Buffer.from('And Desert You', 'ascii'); + const revealData = inscriptions.createInscriptionRevealData( + testPublicKey, + contentType, + inscriptionData, + coinName ); - assert.doesNotThrow(() => { - fullySignedRevealTransaction.validateSignaturesOfAllInputs(); - fullySignedRevealTransaction.finalizeAllInputs(); - }); + + // Create commit tx with WRONG output script + const wrongOutputScript = Buffer.alloc(34); + wrongOutputScript[0] = 0x51; // OP_1 + wrongOutputScript[1] = 0x20; // PUSH32 + // Rest is zeros (different from revealData address) + + const commitTxBytes = createMockCommitTx(wrongOutputScript); + + const recipientOutputScript = Buffer.alloc(22); + recipientOutputScript[0] = 0x00; + recipientOutputScript[1] = 0x14; + const recipientAddress = wasmAddress.fromOutputScriptWithCoin(recipientOutputScript, coinName); + + // Should throw because commit output script doesn't match + assert.throws(() => { + inscriptions.signRevealTransaction( + testPrivateKey, + revealData.tapLeafScript, + revealData.address, // Looking for this address + recipientAddress, + commitTxBytes, + coinName + ); + }, /Commit output not found/); }); }); }); diff --git a/modules/utxo-ord/test/psbt.ts b/modules/utxo-ord/test/psbt.ts index ec9ad5ea7c..b968916808 100644 --- a/modules/utxo-ord/test/psbt.ts +++ b/modules/utxo-ord/test/psbt.ts @@ -5,35 +5,46 @@ import { createPsbtFromOutputLayout, findOutputLayoutForWalletUnspents, WalletInputBuilder, + WalletUnspent, toArray, } from '../src'; import { isSatPoint, OutputLayout, toParameters } from '../src'; -import { bitgo, networks, testutil } from '@bitgo/utxo-lib'; +import { fixedScriptWallet, BIP32, type CoinName } from '@bitgo/wasm-utxo'; +import { getTestWalletKeys, mockWalletUnspent } from './testutils'; + +const coinName: CoinName = 'btc'; function assertValidPsbt( - psbt: bitgo.UtxoPsbt, - s: bitgo.WalletUnspentSigner, + psbt: fixedScriptWallet.BitGoPsbt, + rootWalletKeys: fixedScriptWallet.RootWalletKeys, + signerKeys: [string, string], // xprvs for signer and cosigner expectedOutputs: number, expectedFee: bigint ) { - psbt.signAllInputsHD(s.signer); - psbt.signAllInputsHD(s.cosigner); + const replayProtection = { publicKeys: [] as Uint8Array[] }; + const parsed = psbt.parseTransactionWithWalletKeys(rootWalletKeys, replayProtection); + + // Sign all inputs with both signers + const signer1 = BIP32.fromBase58(signerKeys[0]); + const signer2 = BIP32.fromBase58(signerKeys[1]); + parsed.inputs.forEach((_input, inputIndex) => { + psbt.sign(inputIndex, signer1); + psbt.sign(inputIndex, signer2); + }); + psbt.finalizeAllInputs(); - assert.strictEqual(psbt.txOutputs.length, expectedOutputs); - assert.strictEqual(psbt.getFee(), expectedFee); + + assert.strictEqual(parsed.outputs.length, expectedOutputs); + assert.strictEqual(parsed.minerFee, expectedFee); } describe('OutputLayout to PSBT conversion', function () { - const network = networks.bitcoin; - const walletKeys = testutil.getDefaultWalletKeys(); - const signer = bitgo.WalletUnspentSigner.from(walletKeys, walletKeys.user, walletKeys.bitgo); - const inscriptionRecipient = bitgo.outputScripts.createOutputScript2of3( - walletKeys.deriveForChainAndIndex(0, 0).publicKeys, - 'p2sh' - ).scriptPubKey; - const walletUnspent = testutil.mockWalletUnspent(network, BigInt(20_000), { keys: walletKeys }); + const { rootWalletKeys, signerXprvs } = getTestWalletKeys(); + // Use wasm-utxo to derive the inscription recipient address (chain 0, index 0) + const inscriptionRecipient = fixedScriptWallet.address(rootWalletKeys, 0, 0, coinName); + const walletUnspent = mockWalletUnspent(BigInt(20_000)); const inputBuilder: WalletInputBuilder = { - walletKeys, + walletKeys: rootWalletKeys, signer: 'user', cosigner: 'bitgo', }; @@ -47,8 +58,9 @@ describe('OutputLayout to PSBT conversion', function () { function testInscriptionTxWithLayout(layout: OutputLayout, expectedOutputs: number) { assertValidPsbt( - createPsbtFromOutputLayout(network, inputBuilder, [walletUnspent], outputs, layout), - signer, + createPsbtFromOutputLayout(coinName, inputBuilder, [walletUnspent], outputs, layout), + rootWalletKeys, + signerXprvs, expectedOutputs, layout.feeOutput ); @@ -71,9 +83,9 @@ describe('OutputLayout to PSBT conversion', function () { }); function testWithUnspents( - inscriptionUnspent: bitgo.WalletUnspent, - supplementaryUnspents: bitgo.WalletUnspent[], - expectedUnspentSelection: bitgo.WalletUnspent[], + inscriptionUnspent: WalletUnspent, + supplementaryUnspents: WalletUnspent[], + expectedUnspentSelection: WalletUnspent[], expectedResult: OutputLayout | undefined, { minimizeInputs }: { minimizeInputs?: boolean } = {} ) { @@ -83,7 +95,7 @@ describe('OutputLayout to PSBT conversion', function () { assert.ok(isSatPoint(satPoint)); const f = () => createPsbtForSingleInscriptionPassingTransaction( - network, + coinName, inputBuilder, [inscriptionUnspent], satPoint, @@ -101,8 +113,10 @@ describe('OutputLayout to PSBT conversion', function () { return; } const psbt1 = f(); + const replayProtection = { publicKeys: [] as Uint8Array[] }; + const parsed1 = psbt1.parseTransactionWithWalletKeys(rootWalletKeys, replayProtection); assert.deepStrictEqual( - psbt1.txInputs.map((i) => bitgo.formatOutputId(bitgo.getOutputIdForInput(i))), + parsed1.inputs.map((i) => `${i.previousOutput.txid}:${i.previousOutput.vout}`), expectedUnspentSelection.map((u) => u.id) ); const result = findOutputLayoutForWalletUnspents(expectedUnspentSelection, satPoint, outputs, { @@ -111,19 +125,19 @@ describe('OutputLayout to PSBT conversion', function () { assert.ok(result); assert.deepStrictEqual(result.layout, expectedResult); const expectedOutputs = toArray(expectedResult).filter((v) => v !== BigInt(0)).length - 1; - const psbt = createPsbtFromOutputLayout(network, inputBuilder, expectedUnspentSelection, outputs, result.layout); - assertValidPsbt(psbt, signer, expectedOutputs, expectedResult.feeOutput); - assertValidPsbt(psbt1, signer, expectedOutputs, expectedResult.feeOutput); + const psbt = createPsbtFromOutputLayout(coinName, inputBuilder, expectedUnspentSelection, outputs, result.layout); + assertValidPsbt(psbt, rootWalletKeys, signerXprvs, expectedOutputs, expectedResult.feeOutput); + assertValidPsbt(psbt1, rootWalletKeys, signerXprvs, expectedOutputs, expectedResult.feeOutput); assert.strictEqual( - psbt.extractTransaction().toBuffer().toString('hex'), - psbt1.extractTransaction().toBuffer().toString('hex') + Buffer.from(psbt.extractTransaction()).toString('hex'), + Buffer.from(psbt1.extractTransaction()).toString('hex') ); }); } let nUnspent = 0; - function unspent(v: number): bitgo.WalletUnspent { - return testutil.mockWalletUnspent(network, BigInt(v), { vout: nUnspent++ }); + function unspent(v: number): WalletUnspent { + return mockWalletUnspent(BigInt(v), { vout: nUnspent++ }); } const u1k = unspent(1_000); @@ -148,15 +162,15 @@ describe('OutputLayout to PSBT conversion', function () { { firstChangeOutput: BigInt(0), inscriptionOutput: BigInt(10_000), - secondChangeOutput: BigInt(199990009), - feeOutput: BigInt(991), + secondChangeOutput: BigInt(199990007), + feeOutput: BigInt(993), }, { minimizeInputs: false } ); testWithUnspents(u1k, [u5k1, u5k2, u10k], [u1k, u10k], { firstChangeOutput: BigInt(0), - inscriptionOutput: BigInt(10_350), + inscriptionOutput: BigInt(10_349), secondChangeOutput: BigInt(0), - feeOutput: BigInt(650), + feeOutput: BigInt(651), }); }); diff --git a/modules/utxo-ord/test/testutils.ts b/modules/utxo-ord/test/testutils.ts new file mode 100644 index 0000000000..d97e81fbd6 --- /dev/null +++ b/modules/utxo-ord/test/testutils.ts @@ -0,0 +1,57 @@ +import * as crypto from 'crypto'; +import { fixedScriptWallet, BIP32 } from '@bitgo/wasm-utxo'; +import { WalletUnspent } from '../src'; + +/** + * Create a deterministic BIP32 key from a seed string. + * Uses SHA256 hash of the seed as the BIP32 seed. + */ +export function getKey(seed: string): BIP32 { + return BIP32.fromSeed(crypto.createHash('sha256').update(seed).digest()); +} + +/** + * Create deterministic test wallet keys using wasm-utxo. + * Replicates the behavior of utxo-lib's getDefaultWalletKeys(). + */ +export function getTestWalletKeys(seed = 'default'): { + rootWalletKeys: fixedScriptWallet.RootWalletKeys; + signerXprvs: [string, string]; +} { + // Create BIP32 keys from deterministic seeds (same as utxo-lib testutil) + const userKey = getKey(seed + '.0'); + const backupKey = getKey(seed + '.1'); + const bitgoKey = getKey(seed + '.2'); + + const rootWalletKeys = fixedScriptWallet.RootWalletKeys.fromXpubs([ + userKey.neutered().toBase58(), + backupKey.neutered().toBase58(), + bitgoKey.neutered().toBase58(), + ]); + + return { + rootWalletKeys, + signerXprvs: [userKey.toBase58(), bitgoKey.toBase58()], + }; +} + +/** + * Create a mock wallet unspent for testing. + * @param value - Value in satoshis + * @param chain - Chain code (default: 0) + * @param index - Derivation index (default: 0) + * @param vout - Output index for mock txid (default: 0) + */ +export function mockWalletUnspent( + value: bigint, + { chain = 0, index = 0, vout = 0 }: { chain?: number; index?: number; vout?: number } = {} +): WalletUnspent { + // Create a deterministic mock txid based on vout (must be 64 hex chars) + const mockTxid = vout.toString(16).padStart(64, '0'); + return { + id: `${mockTxid}:${vout}`, + value, + chain, + index, + }; +}