Skip to content

Commit b14a369

Browse files
OttoAllmendingerllm-git
andcommitted
feat(abstract-utxo): use wasm-utxo for backup key recovery
Refactor the backup key recovery flow to use WASM primitives instead of utxolib. Issue: BTC-2891 Co-authored-by: llm-git <llm-git@ttll.de>
1 parent a326192 commit b14a369

7 files changed

Lines changed: 84 additions & 85 deletions

File tree

modules/abstract-utxo/src/recovery/backupKeyRecovery.ts

Lines changed: 47 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
import _ from 'lodash';
2-
import * as utxolib from '@bitgo/utxo-lib';
32
import {
43
BitGoBase,
54
ErrorNoInputToRecover,
@@ -9,32 +8,29 @@ import {
98
getIsUnsignedSweep,
109
isTriple,
1110
krsProviders,
11+
Triple,
1212
} from '@bitgo/sdk-core';
13-
import { getMainnet, networks } from '@bitgo/utxo-lib';
14-
import { fixedScriptWallet } from '@bitgo/wasm-utxo';
13+
import { BIP32, fixedScriptWallet } from '@bitgo/wasm-utxo';
1514

1615
import { AbstractUtxoCoin } from '../abstractUtxoCoin';
1716
import { signAndVerifyPsbt } from '../transaction/fixedScript/signTransaction';
1817
import { generateAddressWithChainAndIndex } from '../address';
1918
import { encodeTransaction } from '../transaction/decode';
2019
import { getReplayProtectionPubkeys } from '../transaction/fixedScript/replayProtection';
21-
import { UtxoCoinName } from '../names';
22-
import type { WalletUnspent } from '../unspent';
20+
import { getMainnetCoinName, UtxoCoinName } from '../names';
21+
import { parseOutputId, unspentSum, type WalletUnspent } from '../unspent';
2322

2423
import { forCoin, RecoveryProvider } from './RecoveryProvider';
2524
import { MempoolApi } from './mempoolApi';
2625
import { CoingeckoApi } from './coingeckoApi';
2726
import { createBackupKeyRecoveryPsbt, getRecoveryAmount } from './psbt';
2827

29-
type ScriptType2Of3 = utxolib.bitgo.outputScripts.ScriptType2Of3;
30-
type ChainCode = utxolib.bitgo.ChainCode;
31-
type RootWalletKeys = utxolib.bitgo.RootWalletKeys;
28+
type ScriptType2Of3 = fixedScriptWallet.OutputScriptType;
29+
type ChainCode = fixedScriptWallet.ChainCode;
3230
type WalletUnspentJSON = WalletUnspent & {
3331
valueString: string;
3432
};
3533

36-
const { getInternalChainCode, scriptTypeForChain, outputScripts, getExternalChainCode } = utxolib.bitgo;
37-
3834
// V1 only deals with BTC. 50 sat/vbyte is very arbitrary.
3935
export const DEFAULT_RECOVERY_FEERATE_SAT_VBYTE_V1 = 50;
4036

@@ -120,7 +116,7 @@ export interface RecoverParams {
120116
function getFormattedAddress(
121117
coin: AbstractUtxoCoin,
122118
coinName: UtxoCoinName,
123-
walletKeys: RootWalletKeys,
119+
walletKeys: fixedScriptWallet.RootWalletKeys,
124120
chain: ChainCode,
125121
addrIndex: number
126122
): string {
@@ -131,15 +127,18 @@ function getFormattedAddress(
131127
return format === 'cashaddr' ? address.split(':')[1] : address;
132128
}
133129

130+
function hasWitnessData(scriptType: ScriptType2Of3): boolean {
131+
return scriptType !== 'p2sh';
132+
}
133+
134134
async function queryBlockchainUnspentsPath(
135135
coin: AbstractUtxoCoin,
136136
params: RecoverParams,
137-
walletKeys: RootWalletKeys,
137+
walletKeys: fixedScriptWallet.RootWalletKeys,
138138
chain: ChainCode
139139
): Promise<WalletUnspent<bigint>[]> {
140-
const scriptType = scriptTypeForChain(chain);
141-
const fetchPrevTx =
142-
!utxolib.bitgo.outputScripts.hasWitnessData(scriptType) && getMainnet(coin.network) !== networks.zcash;
140+
const scriptType = fixedScriptWallet.ChainCode.scriptType(chain);
141+
const fetchPrevTx = !hasWitnessData(scriptType) && getMainnetCoinName(coin.name) !== 'zec';
143142
const recoveryProvider = params.recoveryProvider ?? forCoin(coin.getChain(), params.apiKey);
144143
const MAX_SEQUENTIAL_ADDRESSES_WITHOUT_TXS = params.scan || 20;
145144
let numSequentialAddressesWithoutTxs = 0;
@@ -168,7 +167,7 @@ async function queryBlockchainUnspentsPath(
168167
const addressUnspents = await recoveryProvider.getUnspentsForAddresses([formattedAddress]);
169168
const processedUnspents = await Promise.all(
170169
addressUnspents.map(async (u): Promise<WalletUnspent<bigint>> => {
171-
const { txid, vout } = utxolib.bitgo.parseOutputId(u.id);
170+
const { txid, vout } = parseOutputId(u.id);
172171
let val = BigInt(u.value);
173172
if (coin.amountType === 'bigint') {
174173
// blockchair returns the number with the correct precision, but in number format
@@ -246,6 +245,14 @@ export type BackupKeyRecoveryTransansaction = {
246245
recoveryAmountString: string;
247246
};
248247

248+
function getBip32Privkeys(bitgo: BitGoBase, params: RecoverParams): Triple<BIP32> {
249+
const keys = getBip32Keys(bitgo, params, { requireBitGoXpub: true });
250+
if (!isTriple(keys)) {
251+
throw new Error(`expected key triple`);
252+
}
253+
return keys.map((k) => BIP32.from(k.toBase58())) as Triple<BIP32>;
254+
}
255+
249256
/**
250257
* Builds a funds recovery transaction without BitGo.
251258
*
@@ -303,35 +310,43 @@ export async function backupKeyRecovery(
303310
const krsProvider = isKrsRecovery ? getKrsProvider(coin, params.krsProvider) : undefined;
304311

305312
// check whether key material and password authenticate the users and return parent keys of all three keys of the wallet
306-
const keys = getBip32Keys(bitgo, params, { requireBitGoXpub: true });
307-
if (!isTriple(keys)) {
308-
throw new Error(`expected key triple`);
309-
}
310-
const walletKeys = new utxolib.bitgo.RootWalletKeys(keys, [
311-
params.userKeyPath || utxolib.bitgo.RootWalletKeys.defaultPrefix,
312-
utxolib.bitgo.RootWalletKeys.defaultPrefix,
313-
utxolib.bitgo.RootWalletKeys.defaultPrefix,
314-
]);
313+
const keys = getBip32Privkeys(bitgo, params);
314+
const walletKeys = fixedScriptWallet.RootWalletKeys.from({
315+
triple: keys,
316+
derivationPrefixes: [params.userKeyPath || 'm/0/0', 'm/0/0', 'm/0/0'],
317+
});
315318

316319
const unspents: WalletUnspent<bigint>[] = (
317320
await Promise.all(
318-
outputScripts.scriptTypes2Of3
321+
fixedScriptWallet.outputScriptTypes
319322
.filter(
320-
(addressType) => coin.supportsAddressType(addressType) && !params.ignoreAddressTypes?.includes(addressType)
323+
(addressType) =>
324+
fixedScriptWallet.supportsScriptType(coin.name, addressType) &&
325+
!params.ignoreAddressTypes?.includes(addressType)
321326
)
322327
.reduce(
323328
(queries, addressType) => [
324329
...queries,
325-
queryBlockchainUnspentsPath(coin, params, walletKeys, getExternalChainCode(addressType)),
326-
queryBlockchainUnspentsPath(coin, params, walletKeys, getInternalChainCode(addressType)),
330+
queryBlockchainUnspentsPath(
331+
coin,
332+
params,
333+
walletKeys,
334+
fixedScriptWallet.ChainCode.value(addressType, 'external')
335+
),
336+
queryBlockchainUnspentsPath(
337+
coin,
338+
params,
339+
walletKeys,
340+
fixedScriptWallet.ChainCode.value(addressType, 'internal')
341+
),
327342
],
328343
[] as Promise<WalletUnspent<bigint>[]>[]
329344
)
330345
)
331346
).flat();
332347

333348
// Execute the queries and gather the unspents
334-
const totalInputAmount = utxolib.bitgo.unspentSum(unspents, 'bigint');
349+
const totalInputAmount = unspentSum(unspents);
335350
if (totalInputAmount <= BigInt(0)) {
336351
throw new ErrorNoInputToRecover();
337352
}
@@ -390,7 +405,7 @@ export async function backupKeyRecovery(
390405
const replayProtection = { publicKeys: getReplayProtectionPubkeys(coin.name) };
391406

392407
// Sign with user key first
393-
psbt = signAndVerifyPsbt(psbt, walletKeys.user, rootWalletKeysWasm, replayProtection);
408+
psbt = signAndVerifyPsbt(psbt, keys[0], rootWalletKeysWasm, replayProtection);
394409

395410
if (isKrsRecovery) {
396411
// The KRS provider keyternal solely supports P2SH, P2WSH, and P2SH-P2WSH input script types.
@@ -403,7 +418,7 @@ export async function backupKeyRecovery(
403418
: encodeTransaction(psbt).toString('hex');
404419
} else {
405420
// Sign with backup key
406-
psbt = signAndVerifyPsbt(psbt, walletKeys.backup, rootWalletKeysWasm, replayProtection);
421+
psbt = signAndVerifyPsbt(psbt, keys[1], rootWalletKeysWasm, replayProtection);
407422
// Finalize and extract transaction
408423
psbt.finalizeAllInputs();
409424
txInfo.transactionHex = Buffer.from(psbt.extractTransaction().toBytes()).toString('hex');

modules/abstract-utxo/src/recovery/crossChainRecovery.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -341,7 +341,7 @@ async function getPrv(xprv?: string, passphrase?: string, wallet?: IWallet | Wal
341341
*/
342342
function createSweepTransaction<TNumber extends number | bigint = number>(
343343
coinName: CoinName,
344-
walletKeys: RootWalletKeys,
344+
walletKeys: fixedScriptWallet.RootWalletKeys,
345345
unspents: WalletUnspent<TNumber>[],
346346
targetAddress: string,
347347
feeRateSatVB: number
@@ -410,7 +410,7 @@ export async function recoverCrossChain<TNumber extends number | bigint = number
410410
params.apiKey
411411
);
412412
const walletUnspents = await toWalletUnspents<TNumber>(params.sourceCoin, params.recoveryCoin, unspents, wallet);
413-
const walletKeys = await getWalletKeys(params.recoveryCoin, wallet);
413+
const walletKeys = fixedScriptWallet.RootWalletKeys.from(await getWalletKeys(params.recoveryCoin, wallet));
414414
const prv =
415415
params.xprv || params.walletPassphrase ? await getPrv(params.xprv, params.walletPassphrase, wallet) : undefined;
416416
const feeRateSatVB = await getFeeRateSatVB(params.sourceCoin);
@@ -435,7 +435,7 @@ export async function recoverCrossChain<TNumber extends number | bigint = number
435435
}
436436

437437
// For signed recovery, sign the PSBT with user key and return half-signed PSBT
438-
psbt = signAndVerifyPsbt(psbt, prv, fixedScriptWallet.RootWalletKeys.from(walletKeys), {
438+
psbt = signAndVerifyPsbt(psbt, prv, walletKeys, {
439439
publicKeys: getReplayProtectionPubkeys(params.sourceCoin.name),
440440
});
441441

modules/abstract-utxo/src/recovery/psbt.ts

Lines changed: 11 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,13 @@
1-
import * as utxolib from '@bitgo/utxo-lib';
21
import { CoinName, fixedScriptWallet, address as wasmAddress } from '@bitgo/wasm-utxo';
32

4-
import { getNetworkFromCoinName } from '../names';
5-
import type { WalletUnspent } from '../unspent';
6-
7-
type RootWalletKeys = utxolib.bitgo.RootWalletKeys;
8-
9-
const { chainCodesP2tr, chainCodesP2trMusig2 } = utxolib.bitgo;
10-
11-
type ChainCode = utxolib.bitgo.ChainCode;
3+
import { parseOutputId, unspentSum, type WalletUnspent } from '../unspent';
124

135
/**
146
* Check if a chain code is for a taproot script type
157
*/
16-
export function isTaprootChain(chain: ChainCode): boolean {
17-
return (
18-
(chainCodesP2tr as readonly number[]).includes(chain) || (chainCodesP2trMusig2 as readonly number[]).includes(chain)
19-
);
8+
export function isTaprootChain(chain: fixedScriptWallet.ChainCode): boolean {
9+
const scriptType = fixedScriptWallet.ChainCode.scriptType(chain);
10+
return scriptType === 'p2trLegacy' || scriptType === 'p2trMusig2';
2011
}
2112

2213
class InsufficientFundsError extends Error {
@@ -81,7 +72,7 @@ export interface CreateEmptyWasmPsbtOptions {
8172
*/
8273
export function createEmptyWasmPsbt(
8374
coinName: CoinName,
84-
rootWalletKeys: RootWalletKeys,
75+
rootWalletKeys: fixedScriptWallet.RootWalletKeys,
8576
options?: CreateEmptyWasmPsbtOptions
8677
): fixedScriptWallet.BitGoPsbt {
8778
if (isZcash(coinName)) {
@@ -106,10 +97,10 @@ export function createEmptyWasmPsbt(
10697
export function addWalletInputsToWasmPsbt(
10798
wasmPsbt: fixedScriptWallet.BitGoPsbt,
10899
unspents: WalletUnspent<bigint>[],
109-
rootWalletKeys: RootWalletKeys
100+
rootWalletKeys: fixedScriptWallet.RootWalletKeys
110101
): void {
111102
unspents.forEach((unspent) => {
112-
const { txid, vout } = utxolib.bitgo.parseOutputId(unspent.id);
103+
const { txid, vout } = parseOutputId(unspent.id);
113104
const signPath: fixedScriptWallet.SignPath | undefined = isTaprootChain(unspent.chain)
114105
? { signer: 'user', cosigner: 'backup' }
115106
: undefined;
@@ -152,30 +143,12 @@ export function addOutputToWasmPsbt(
152143
return wasmPsbt.addOutput({ script, value });
153144
}
154145

155-
/**
156-
* Convert a wasm-utxo BitGoPsbt to a utxolib UtxoPsbt.
157-
*
158-
* @param wasmPsbt - The wasm-utxo BitGoPsbt to convert
159-
* @param network - The network
160-
* @returns A utxolib UtxoPsbt
161-
*/
162-
export function toPsbtToUtxolibPsbt(
163-
wasmPsbt: fixedScriptWallet.BitGoPsbt | utxolib.bitgo.UtxoPsbt,
164-
coinName: CoinName
165-
): utxolib.bitgo.UtxoPsbt {
166-
if (wasmPsbt instanceof fixedScriptWallet.BitGoPsbt) {
167-
const network = getNetworkFromCoinName(coinName);
168-
return utxolib.bitgo.createPsbtFromBuffer(Buffer.from(wasmPsbt.serialize()), network);
169-
}
170-
return wasmPsbt;
171-
}
172-
173146
/**
174147
* Create a backup key recovery PSBT using wasm-utxo
175148
*/
176149
function createBackupKeyRecoveryPsbtWasm(
177150
coinName: CoinName,
178-
rootWalletKeys: RootWalletKeys,
151+
rootWalletKeys: fixedScriptWallet.RootWalletKeys,
179152
unspents: WalletUnspent<bigint>[],
180153
options: CreateBackupKeyRecoveryPsbtOptions
181154
): fixedScriptWallet.BitGoPsbt {
@@ -195,7 +168,7 @@ function createBackupKeyRecoveryPsbtWasm(
195168
}
196169

197170
const approximateFee = BigInt(dimensions.getVSize() * feeRateSatVB);
198-
const totalInputAmount = utxolib.bitgo.unspentSum(unspents, 'bigint');
171+
const totalInputAmount = unspentSum(unspents);
199172
const recoveryAmount = totalInputAmount - approximateFee - keyRecoveryServiceFee;
200173

201174
if (recoveryAmount < BigInt(0)) {
@@ -222,7 +195,7 @@ function createBackupKeyRecoveryPsbtWasm(
222195
*/
223196
export function createBackupKeyRecoveryPsbt(
224197
coinName: CoinName,
225-
rootWalletKeys: RootWalletKeys,
198+
rootWalletKeys: fixedScriptWallet.RootWalletKeys,
226199
unspents: WalletUnspent<bigint>[],
227200
options: CreateBackupKeyRecoveryPsbtOptions
228201
): fixedScriptWallet.BitGoPsbt {
@@ -235,7 +208,7 @@ export function createBackupKeyRecoveryPsbt(
235208

236209
export function getRecoveryAmount(
237210
psbt: fixedScriptWallet.BitGoPsbt,
238-
walletKeys: RootWalletKeys,
211+
walletKeys: fixedScriptWallet.RootWalletKeys,
239212
address: string
240213
): bigint {
241214
const parsedOutputs = psbt.parseOutputsWithWalletKeys(walletKeys);

modules/abstract-utxo/src/transaction/fixedScript/signPsbtUtxolib.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ export type PsbtParsedScriptType =
3535
*/
3636
export function signAndVerifyPsbt(
3737
psbt: utxolib.bitgo.UtxoPsbt,
38-
signerKeychain: utxolib.BIP32Interface
38+
signerKeychain: BIP32Interface
3939
): utxolib.bitgo.UtxoPsbt {
4040
const txInputs = psbt.txInputs;
4141
const outputIds: string[] = [];

modules/abstract-utxo/src/transaction/fixedScript/signPsbtWasm.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import assert from 'assert';
22

33
import { BIP32Interface } from '@bitgo/utxo-lib';
4-
import { ECPair, fixedScriptWallet } from '@bitgo/wasm-utxo';
4+
import { BIP32, ECPair, fixedScriptWallet } from '@bitgo/wasm-utxo';
55

66
import { toWasmBIP32 } from '../../wasmUtil';
77

@@ -36,7 +36,7 @@ function hasKeyPathSpendInput(
3636
*/
3737
export function signAndVerifyPsbtWasm(
3838
tx: fixedScriptWallet.BitGoPsbt,
39-
signerKeychain: BIP32Interface,
39+
signerKeychain: BIP32Interface | BIP32,
4040
rootWalletKeys: fixedScriptWallet.RootWalletKeys,
4141
replayProtection: ReplayProtectionKeys
4242
): fixedScriptWallet.BitGoPsbt {

modules/abstract-utxo/src/transaction/fixedScript/signTransaction.ts

Lines changed: 11 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import _ from 'lodash';
55
import { BIP32Interface } from '@bitgo/secp256k1';
66
import { bitgo } from '@bitgo/utxo-lib';
77
import * as utxolib from '@bitgo/utxo-lib';
8-
import { fixedScriptWallet } from '@bitgo/wasm-utxo';
8+
import { BIP32, fixedScriptWallet } from '@bitgo/wasm-utxo';
99

1010
import { UtxoCoinName } from '../../names';
1111
import type { Unspent } from '../../unspent';
@@ -21,35 +21,37 @@ import { getReplayProtectionPubkeys } from './replayProtection';
2121
*/
2222
export function signAndVerifyPsbt(
2323
psbt: utxolib.bitgo.UtxoPsbt,
24-
signerKeychain: BIP32Interface,
24+
signerKeychain: BIP32Interface | BIP32,
2525
rootWalletKeys: fixedScriptWallet.RootWalletKeys | undefined,
2626
replayProtection: ReplayProtectionKeys | undefined
2727
): utxolib.bitgo.UtxoPsbt;
2828
export function signAndVerifyPsbt(
2929
psbt: fixedScriptWallet.BitGoPsbt,
30-
signerKeychain: BIP32Interface,
30+
signerKeychain: BIP32Interface | BIP32,
3131
rootWalletKeys: fixedScriptWallet.RootWalletKeys,
3232
replayProtection: ReplayProtectionKeys
3333
): fixedScriptWallet.BitGoPsbt;
3434
export function signAndVerifyPsbt(
3535
psbt: utxolib.bitgo.UtxoPsbt | fixedScriptWallet.BitGoPsbt,
36-
signerKeychain: BIP32Interface,
36+
signerKeychain: BIP32Interface | BIP32,
3737
rootWalletKeys: fixedScriptWallet.RootWalletKeys,
3838
replayProtection: ReplayProtectionKeys
3939
): utxolib.bitgo.UtxoPsbt | fixedScriptWallet.BitGoPsbt;
4040
export function signAndVerifyPsbt(
4141
psbt: utxolib.bitgo.UtxoPsbt | fixedScriptWallet.BitGoPsbt,
42-
signerKeychain: BIP32Interface,
42+
signerKeychain: BIP32Interface | BIP32,
4343
rootWalletKeys: fixedScriptWallet.RootWalletKeys | undefined,
4444
replayProtection: ReplayProtectionKeys | undefined
4545
): utxolib.bitgo.UtxoPsbt | fixedScriptWallet.BitGoPsbt {
4646
if (psbt instanceof bitgo.UtxoPsbt) {
47+
if (signerKeychain instanceof BIP32) {
48+
signerKeychain = utxolib.bip32.fromBase58(signerKeychain.toBase58());
49+
}
4750
return signAndVerifyPsbtUtxolib(psbt, signerKeychain);
48-
} else {
49-
assert(rootWalletKeys, 'rootWalletKeys required for wasm-utxo signing');
50-
assert(replayProtection, 'replayProtection required for wasm-utxo signing');
51-
return signAndVerifyPsbtWasm(psbt, signerKeychain, rootWalletKeys, replayProtection);
5251
}
52+
assert(rootWalletKeys, 'rootWalletKeys required for wasm-utxo signing');
53+
assert(replayProtection, 'replayProtection required for wasm-utxo signing');
54+
return signAndVerifyPsbtWasm(psbt, signerKeychain, rootWalletKeys, replayProtection);
5355
}
5456

5557
export async function signTransaction<

0 commit comments

Comments
 (0)