Skip to content

Commit 71697b1

Browse files
OttoAllmendingerllm-git
andcommitted
feat(abstract-utxo): migrate inscription builder to wasm-utxo
Replace utxo-lib with wasm-utxo implementation for inscription handling. This provides a more efficient and robust way to create and sign inscription transactions using WebAssembly. Key changes: - Replace utxo-lib dependency with wasm-utxo in inscription functions - Update API to use typed arrays instead of Node Buffers - Add coin name mapping for different Bitcoin networks - Improve error handling and type safety Issue: BTC-2936 Co-authored-by: llm-git <llm-git@ttll.de>
1 parent 16e5c6b commit 71697b1

File tree

9 files changed

+568
-355
lines changed

9 files changed

+568
-355
lines changed

modules/abstract-utxo/src/impl/btc/inscriptionBuilder.ts

Lines changed: 98 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,6 @@ import {
1111
xprvToRawPrv,
1212
xpubToCompressedPub,
1313
} from '@bitgo/sdk-core';
14-
import * as utxolib from '@bitgo/utxo-lib';
1514
import {
1615
createPsbtForSingleInscriptionPassingTransaction,
1716
DefaultInscriptionConstraints,
@@ -23,13 +22,39 @@ import {
2322
findOutputLayoutForWalletUnspents,
2423
MAX_UNSPENTS_FOR_OUTPUT_LAYOUT,
2524
SatPoint,
25+
WalletUnspent,
26+
type TapLeafScript,
2627
} from '@bitgo/utxo-ord';
28+
import { fixedScriptWallet, type CoinName } from '@bitgo/wasm-utxo';
2729

28-
import { AbstractUtxoCoin, RootWalletKeys } from '../../abstractUtxoCoin';
29-
import { getWalletKeys } from '../../recovery/crossChainRecovery';
30+
import { AbstractUtxoCoin } from '../../abstractUtxoCoin';
31+
32+
/** Key identifier for signing */
33+
type SignerKey = 'user' | 'backup' | 'bitgo';
34+
35+
/** Unspent from wallet API (value may be number or bigint) */
36+
type WalletUnspentLike = {
37+
id: string;
38+
value: number | bigint;
39+
chain: number;
40+
index: number;
41+
};
42+
43+
const { RootWalletKeys: WasmRootWalletKeys } = fixedScriptWallet;
3044

3145
const SUPPLEMENTARY_UNSPENTS_MIN_VALUE_SATS = [0, 20_000, 200_000];
3246

47+
/** Map coin chain to CoinName for wasm-utxo */
48+
function getCoinName(coin: AbstractUtxoCoin): CoinName {
49+
const chain = coin.getChain();
50+
// CoinName matches the chain name for Bitcoin
51+
const validCoins: CoinName[] = ['btc', 'tbtc', 'tbtc4', 'tbtcsig', 'tbtcbgsig'];
52+
if (validCoins.includes(chain as CoinName)) {
53+
return chain as CoinName;
54+
}
55+
throw new Error(`Unsupported coin for inscriptions: ${chain}`);
56+
}
57+
3358
export class InscriptionBuilder implements IInscriptionBuilder {
3459
private readonly wallet: IWallet;
3560
private readonly coin: AbstractUtxoCoin;
@@ -45,9 +70,21 @@ export class InscriptionBuilder implements IInscriptionBuilder {
4570

4671
const derived = this.coin.deriveKeyWithSeed({ key: user.pub, seed: inscriptionData.toString() });
4772
const compressedPublicKey = xpubToCompressedPub(derived.key);
48-
const xOnlyPublicKey = utxolib.bitgo.outputScripts.toXOnlyPublicKey(Buffer.from(compressedPublicKey, 'hex'));
73+
const xOnlyPublicKey = Buffer.from(compressedPublicKey, 'hex').subarray(1); // Remove parity byte
4974

50-
return inscriptions.createInscriptionRevealData(xOnlyPublicKey, contentType, inscriptionData, this.coin.network);
75+
const coinName = getCoinName(this.coin);
76+
const result = inscriptions.createInscriptionRevealData(xOnlyPublicKey, contentType, inscriptionData, coinName);
77+
78+
// Convert TapLeafScript to utxolib format for backwards compatibility
79+
return {
80+
address: result.address,
81+
revealTransactionVSize: result.revealTransactionVSize,
82+
tapLeafScript: {
83+
controlBlock: Buffer.from(result.tapLeafScript.controlBlock),
84+
script: Buffer.from(result.tapLeafScript.script),
85+
leafVersion: result.tapLeafScript.leafVersion,
86+
},
87+
};
5188
}
5289

5390
private async prepareTransferWithExtraInputs(
@@ -59,36 +96,42 @@ export class InscriptionBuilder implements IInscriptionBuilder {
5996
inscriptionConstraints,
6097
txFormat,
6198
}: {
62-
signer: utxolib.bitgo.KeyName;
63-
cosigner: utxolib.bitgo.KeyName;
99+
signer: SignerKey;
100+
cosigner: SignerKey;
64101
inscriptionConstraints: {
65102
minChangeOutput?: bigint;
66103
minInscriptionOutput?: bigint;
67104
maxInscriptionOutput?: bigint;
68105
};
69106
txFormat?: 'psbt' | 'legacy';
70107
},
71-
rootWalletKeys: RootWalletKeys,
108+
rootWalletKeys: fixedScriptWallet.RootWalletKeys,
72109
outputs: InscriptionOutputs,
73-
inscriptionUnspents: utxolib.bitgo.WalletUnspent<bigint>[],
110+
inscriptionUnspents: WalletUnspent[],
74111
supplementaryUnspentsMinValue: number
75112
): Promise<PrebuildTransactionResult> {
76-
let supplementaryUnspents: utxolib.bitgo.WalletUnspent<bigint>[] = [];
113+
let supplementaryUnspents: WalletUnspent[] = [];
77114
if (supplementaryUnspentsMinValue > 0) {
78115
const response = await this.wallet.unspents({
79116
minValue: supplementaryUnspentsMinValue,
80117
});
81118
// Filter out the inscription unspent from the supplementary unspents
82119
supplementaryUnspents = response.unspents
83-
.filter((unspent) => unspent.id !== inscriptionUnspents[0].id)
120+
.filter((unspent: { id: string }) => unspent.id !== inscriptionUnspents[0].id)
84121
.slice(0, MAX_UNSPENTS_FOR_OUTPUT_LAYOUT - 1)
85-
.map((unspent) => {
86-
unspent.value = BigInt(unspent.value);
87-
return unspent;
88-
});
122+
.map(
123+
(unspent: WalletUnspentLike): WalletUnspent => ({
124+
id: unspent.id,
125+
value: BigInt(unspent.value),
126+
chain: unspent.chain,
127+
index: unspent.index,
128+
})
129+
);
89130
}
131+
132+
const coinName = getCoinName(this.coin);
90133
const psbt = createPsbtForSingleInscriptionPassingTransaction(
91-
this.coin.network,
134+
coinName,
92135
{
93136
walletKeys: rootWalletKeys,
94137
signer,
@@ -117,7 +160,7 @@ export class InscriptionBuilder implements IInscriptionBuilder {
117160
}
118161
return {
119162
walletId: this.wallet.id(),
120-
txHex: txFormat === 'psbt' ? psbt.toHex() : psbt.getUnsignedTx().toHex(),
163+
txHex: Buffer.from(psbt.serialize()).toString('hex'),
121164
txInfo: { unspents: allUnspents },
122165
feeInfo: { fee: Number(outputLayout.layout.feeOutput), feeString: outputLayout.layout.feeOutput.toString() },
123166
};
@@ -146,27 +189,36 @@ export class InscriptionBuilder implements IInscriptionBuilder {
146189
changeAddressType = 'p2wsh',
147190
txFormat = 'psbt',
148191
}: {
149-
signer?: utxolib.bitgo.KeyName;
150-
cosigner?: utxolib.bitgo.KeyName;
192+
signer?: SignerKey;
193+
cosigner?: SignerKey;
151194
inscriptionConstraints?: {
152195
minChangeOutput?: bigint;
153196
minInscriptionOutput?: bigint;
154197
maxInscriptionOutput?: bigint;
155198
};
156-
changeAddressType?: utxolib.bitgo.outputScripts.ScriptType2Of3;
199+
changeAddressType?: 'p2sh' | 'p2shP2wsh' | 'p2wsh' | 'p2tr' | 'p2trMusig2';
157200
txFormat?: 'psbt' | 'legacy';
158201
}
159202
): Promise<PrebuildTransactionResult> {
160203
assert(isSatPoint(satPoint));
161204

162-
const rootWalletKeys = await getWalletKeys(this.coin, this.wallet);
205+
const rootWalletKeys = await this.getWasmWalletKeys();
163206
const parsedSatPoint = parseSatPoint(satPoint);
164207
const transaction = await this.wallet.getTransaction({ txHash: parsedSatPoint.txid });
165-
const unspents: utxolib.bitgo.WalletUnspent<bigint>[] = [transaction.outputs[parsedSatPoint.vout]];
166-
unspents[0].value = BigInt(unspents[0].value);
208+
const output = transaction.outputs[parsedSatPoint.vout];
209+
const unspents: WalletUnspent[] = [
210+
{
211+
id: `${parsedSatPoint.txid}:${parsedSatPoint.vout}`,
212+
value: BigInt(output.value),
213+
chain: output.chain,
214+
index: output.index,
215+
},
216+
];
217+
218+
const changeChain = fixedScriptWallet.ChainCode.value(changeAddressType, 'internal');
167219

168220
const changeAddress = await this.wallet.createAddress({
169-
chain: utxolib.bitgo.getInternalChainCode(changeAddressType),
221+
chain: changeChain,
170222
});
171223
const outputs: InscriptionOutputs = {
172224
inscriptionRecipient: recipient,
@@ -197,6 +249,22 @@ export class InscriptionBuilder implements IInscriptionBuilder {
197249
throw new Error('Fee too high for the selected unspent with this fee rate'); // Exhausted all tries to supplement
198250
}
199251

252+
/**
253+
* Get wallet keys as wasm-utxo RootWalletKeys
254+
*/
255+
private async getWasmWalletKeys(): Promise<fixedScriptWallet.RootWalletKeys> {
256+
const keychainIds = this.wallet.keyIds();
257+
const [user, backup, bitgo] = await Promise.all([
258+
this.wallet.baseCoin.keychains().get({ id: keychainIds[KeyIndices.USER] }),
259+
this.wallet.baseCoin.keychains().get({ id: keychainIds[KeyIndices.BACKUP] }),
260+
this.wallet.baseCoin.keychains().get({ id: keychainIds[KeyIndices.BITGO] }),
261+
]);
262+
263+
assert(user.pub && backup.pub && bitgo.pub, 'Missing wallet public keys');
264+
265+
return WasmRootWalletKeys.from([user.pub, backup.pub, bitgo.pub]);
266+
}
267+
200268
/**
201269
*
202270
* @param walletPassphrase
@@ -209,10 +277,10 @@ export class InscriptionBuilder implements IInscriptionBuilder {
209277
*/
210278
async signAndSendReveal(
211279
walletPassphrase: string,
212-
tapLeafScript: utxolib.bitgo.TapLeafScript,
280+
tapLeafScript: TapLeafScript,
213281
commitAddress: string,
214282
unsignedCommitTx: Buffer,
215-
commitTransactionUnspents: utxolib.bitgo.WalletUnspent[],
283+
commitTransactionUnspents: WalletUnspentLike[],
216284
recipientAddress: string,
217285
inscriptionData: Buffer
218286
): Promise<SubmitTransactionResponse> {
@@ -230,19 +298,20 @@ export class InscriptionBuilder implements IInscriptionBuilder {
230298
const derived = this.coin.deriveKeyWithSeed({ key: xprv, seed: inscriptionData.toString() });
231299
const prv = xprvToRawPrv(derived.key);
232300

233-
const fullySignedRevealTransaction = await inscriptions.signRevealTransaction(
301+
const coinName = getCoinName(this.coin);
302+
const fullySignedRevealTransaction = inscriptions.signRevealTransaction(
234303
Buffer.from(prv, 'hex'),
235304
tapLeafScript,
236305
commitAddress,
237306
recipientAddress,
238307
Buffer.from(halfSignedCommitTransaction.txHex, 'hex'),
239-
this.coin.network
308+
coinName
240309
);
241310

242311
return this.wallet.submitTransaction({
243312
halfSigned: {
244313
txHex: halfSignedCommitTransaction.txHex,
245-
signedChildPsbt: fullySignedRevealTransaction.toHex(),
314+
signedChildPsbt: Buffer.from(fullySignedRevealTransaction).toString('hex'),
246315
},
247316
});
248317
}

modules/utxo-ord/package.json

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,8 +28,9 @@
2828
"directory": "modules/utxo-ord"
2929
},
3030
"dependencies": {
31-
"@bitgo/sdk-core": "^36.27.0",
32-
"@bitgo/unspents": "^0.50.14",
31+
"@bitgo/wasm-utxo": "^1.27.0"
32+
},
33+
"devDependencies": {
3334
"@bitgo/utxo-lib": "^11.19.1"
3435
},
3536
"lint-staged": {

modules/utxo-ord/src/SatPoint.ts

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,10 +9,29 @@ https://github.com/casey/ord/blob/master/bip.mediawiki#terminology-and-notation
99
> `680df1e4d43016571e504b0b142ee43c5c0b83398a97bdcfd94ea6f287322d22:0:6`
1010
1111
*/
12-
import { bitgo } from '@bitgo/utxo-lib';
1312

1413
export type SatPoint = `${string}:${number}:${bigint}`;
1514

15+
/**
16+
* Parse an output ID (txid:vout) into its components.
17+
*/
18+
export function parseOutputId(outputId: string): { txid: string; vout: number } {
19+
const colonIndex = outputId.lastIndexOf(':');
20+
if (colonIndex === -1) {
21+
throw new Error(`Invalid output id format: missing colon`);
22+
}
23+
const txid = outputId.slice(0, colonIndex);
24+
const voutStr = outputId.slice(colonIndex + 1);
25+
if (txid.length !== 64 || !/^[0-9a-fA-F]+$/.test(txid)) {
26+
throw new Error(`Invalid txid: must be 64 hex characters`);
27+
}
28+
const vout = parseInt(voutStr, 10);
29+
if (isNaN(vout) || vout < 0) {
30+
throw new Error(`Invalid vout: must be non-negative integer`);
31+
}
32+
return { txid, vout };
33+
}
34+
1635
export function parseSatPoint(p: SatPoint): { txid: string; vout: number; offset: bigint } {
1736
const parts = p.split(':');
1837
if (parts.length !== 3) {
@@ -27,7 +46,7 @@ export function parseSatPoint(p: SatPoint): { txid: string; vout: number; offset
2746
throw new Error(`SatPoint offset must be positive`);
2847
}
2948
return {
30-
...bitgo.parseOutputId([txid, vout].join(':')),
49+
...parseOutputId([txid, vout].join(':')),
3150
offset,
3251
};
3352
}

modules/utxo-ord/src/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,3 +8,5 @@ export * from './OutputLayout';
88
export * from './SatPoint';
99
export * from './psbt';
1010
export * as inscriptions from './inscriptions';
11+
export type { TapLeafScript, PreparedInscriptionRevealData } from './inscriptions';
12+
export type { WalletUnspent } from './psbt';

0 commit comments

Comments
 (0)