Skip to content

Commit 773be2f

Browse files
OttoAllmendingerllm-git
andcommitted
feat(abstract-utxo): refactor backup key recovery
Refactor backupKeyRecovery to use wasm-utxo primitives for transaction building and signing. Split the implementation into smaller functions to improve testability and maintainability: 1. Extract core PSBT creation logic to backupKeyRecoveryWithWalletUnspents 2. Create formatBackupKeyRecoveryResult for response formatting 3. Add type-specific interfaces for improved type safety 4. Update tests to use wasm-utxo without utxolib dependencies Issue: BTC-2866 Co-authored-by: llm-git <llm-git@ttll.de>
1 parent b1b2c8f commit 773be2f

365 files changed

Lines changed: 1644 additions & 3668 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

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

Lines changed: 208 additions & 79 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,7 @@ import {
33
BitGoBase,
44
ErrorNoInputToRecover,
55
getKrsProvider,
6-
getBip32Keys,
7-
getIsKrsRecovery,
8-
getIsUnsignedSweep,
6+
getBip32Keys as getBip32KeysFromSdkCore,
97
isTriple,
108
krsProviders,
119
Triple,
@@ -245,8 +243,175 @@ export type BackupKeyRecoveryTransansaction = {
245243
recoveryAmountString: string;
246244
};
247245

248-
function getBip32Privkeys(bitgo: BitGoBase, params: RecoverParams): Triple<BIP32> {
249-
const keys = getBip32Keys(bitgo, params, { requireBitGoXpub: true });
246+
/**
247+
* Parameters for backup key recovery PSBT creation.
248+
* All fields are pre-validated and derived - no string key parsing needed.
249+
*/
250+
export interface RecoverWithUnspentsParams {
251+
/** Pre-derived wallet keys */
252+
walletKeys: fixedScriptWallet.RootWalletKeys;
253+
/** Pre-derived key triple (user, backup, bitgo). Check privateKey to determine signing capability. */
254+
keys: Triple<BIP32>;
255+
/** Validated recovery destination address */
256+
recoveryDestination: string;
257+
/** Fee rate in satoshi per vbyte */
258+
feeRateSatVB: number;
259+
/** KRS fee amount in satoshis (0 if not KRS recovery) */
260+
krsFee?: bigint;
261+
/** KRS fee address (required if krsFee > 0) */
262+
krsFeeAddress?: string;
263+
}
264+
265+
function hasPrivateKey(key: BIP32): boolean {
266+
return key.privateKey !== undefined;
267+
}
268+
269+
/**
270+
* Builds a funds recovery PSBT without BitGo, using provided unspents.
271+
*
272+
* This is the core transaction building logic, separated from unspent gathering
273+
* and output formatting. Returns a PSBT at the appropriate signing stage.
274+
*
275+
* Signing behavior is determined by the keys:
276+
* - If user key has no private key: unsigned PSBT
277+
* - If user key has private key but backup doesn't: half-signed PSBT (user signature only)
278+
* - If both user and backup keys have private keys: fully signed PSBT (not finalized)
279+
*
280+
* @param coinName - The coin name for the PSBT
281+
* @param params - Recovery parameters with pre-derived keys
282+
* @param unspents - The wallet unspents to recover (must be non-empty)
283+
* @returns The PSBT at the appropriate signing stage (never finalized)
284+
*/
285+
export function backupKeyRecoveryWithWalletUnspents(
286+
coinName: UtxoCoinName,
287+
params: RecoverWithUnspentsParams,
288+
unspents: WalletUnspent<bigint>[]
289+
): fixedScriptWallet.BitGoPsbt {
290+
const { walletKeys, keys, recoveryDestination, feeRateSatVB, krsFee, krsFeeAddress } = params;
291+
292+
const totalInputAmount = unspentSum(unspents);
293+
if (totalInputAmount <= BigInt(0)) {
294+
throw new ErrorNoInputToRecover();
295+
}
296+
297+
let psbt = createBackupKeyRecoveryPsbt(coinName, walletKeys, unspents, {
298+
feeRateSatVB: feeRateSatVB,
299+
recoveryDestination: recoveryDestination,
300+
keyRecoveryServiceFee: krsFee ?? BigInt(0),
301+
keyRecoveryServiceFeeAddress: krsFeeAddress,
302+
});
303+
304+
const userHasPrivateKey = hasPrivateKey(keys[0]);
305+
const backupHasPrivateKey = hasPrivateKey(keys[1]);
306+
307+
if (!userHasPrivateKey) {
308+
// Unsigned sweep - return unsigned PSBT
309+
return psbt;
310+
}
311+
312+
const replayProtection = { publicKeys: getReplayProtectionPubkeys(coinName) };
313+
314+
// Sign with user key
315+
psbt = signAndVerifyPsbt(psbt, keys[0], walletKeys, replayProtection);
316+
317+
if (backupHasPrivateKey) {
318+
// Full recovery - sign with backup key too
319+
psbt = signAndVerifyPsbt(psbt, keys[1], walletKeys, replayProtection);
320+
}
321+
322+
// Return PSBT (not finalized - let caller decide how to format)
323+
return psbt;
324+
}
325+
326+
/**
327+
* Parameters for formatting a backup key recovery result.
328+
*/
329+
export interface FormatBackupKeyRecoveryParams {
330+
/** Pre-derived wallet keys */
331+
walletKeys: fixedScriptWallet.RootWalletKeys;
332+
/** Pre-derived key triple (user, backup, bitgo). Check privateKey to determine signing capability. */
333+
keys: Triple<BIP32>;
334+
/** Recovery destination address */
335+
recoveryDestination: string;
336+
/** KRS provider name (if backup key is held by KRS) */
337+
krsProvider?: string;
338+
/** Original backup key string (needed for KRS recovery response) */
339+
backupKey?: string;
340+
/** The wallet unspents (needed for inputs array in response) */
341+
unspents: WalletUnspent<bigint>[];
342+
}
343+
344+
/**
345+
* Formats a backup key recovery PSBT into the appropriate response format.
346+
*
347+
* Output format depends on signing state and KRS provider:
348+
* - Unsigned sweep: FormattedOfflineVaultTxInfo with PSBT hex
349+
* - KRS keyternal: BackupKeyRecoveryTransansaction with legacy half-signed tx hex
350+
* - KRS other: BackupKeyRecoveryTransansaction with PSBT hex
351+
* - Full recovery: BackupKeyRecoveryTransansaction with finalized tx hex
352+
*
353+
* @param coin - The coin instance
354+
* @param psbt - The PSBT to format (at appropriate signing stage)
355+
* @param params - Formatting parameters
356+
* @returns The formatted recovery result
357+
*/
358+
export function formatBackupKeyRecoveryResult(
359+
coin: AbstractUtxoCoin,
360+
psbt: fixedScriptWallet.BitGoPsbt,
361+
params: FormatBackupKeyRecoveryParams
362+
): BackupKeyRecoveryTransansaction | FormattedOfflineVaultTxInfo {
363+
const { walletKeys, keys, recoveryDestination, krsProvider, backupKey, unspents } = params;
364+
365+
const userHasPrivateKey = hasPrivateKey(keys[0]);
366+
const backupHasPrivateKey = hasPrivateKey(keys[1]);
367+
368+
const isUnsignedSweep = !userHasPrivateKey && !backupHasPrivateKey;
369+
const isKrsRecovery = krsProvider !== undefined && userHasPrivateKey && !backupHasPrivateKey;
370+
const isFullRecovery = userHasPrivateKey && backupHasPrivateKey;
371+
372+
// Unsigned sweep - return FormattedOfflineVaultTxInfo
373+
if (isUnsignedSweep) {
374+
return {
375+
txHex: encodeTransaction(psbt).toString('hex'),
376+
txInfo: {},
377+
feeInfo: {},
378+
coin: coin.getChain(),
379+
};
380+
}
381+
382+
const responseTxFormat = !isKrsRecovery || krsProvider === 'keyternal' ? 'legacy' : 'psbt';
383+
const txInfo = {} as BackupKeyRecoveryTransansaction;
384+
385+
// Include inputs array for legacy format responses
386+
txInfo.inputs =
387+
responseTxFormat === 'legacy'
388+
? unspents.map((u) => ({ ...u, value: Number(u.value), valueString: u.value.toString(), prevTx: undefined }))
389+
: undefined;
390+
391+
if (isKrsRecovery) {
392+
// KRS recovery - half-signed
393+
// keyternal uses legacy format, other KRS providers use PSBT format
394+
txInfo.transactionHex =
395+
krsProvider === 'keyternal'
396+
? Buffer.from(psbt.getHalfSignedLegacyFormat()).toString('hex')
397+
: encodeTransaction(psbt).toString('hex');
398+
399+
txInfo.coin = coin.getChain();
400+
txInfo.backupKey = backupKey ?? '';
401+
const recoveryAmount = getRecoveryAmount(psbt, walletKeys, recoveryDestination);
402+
txInfo.recoveryAmount = Number(recoveryAmount);
403+
txInfo.recoveryAmountString = recoveryAmount.toString();
404+
} else if (isFullRecovery) {
405+
// Full recovery - finalize and extract transaction
406+
psbt.finalizeAllInputs();
407+
txInfo.transactionHex = Buffer.from(psbt.extractTransaction().toBytes()).toString('hex');
408+
}
409+
410+
return txInfo;
411+
}
412+
413+
function getBip32Keys(bitgo: BitGoBase, params: RecoverParams): Triple<BIP32> {
414+
const keys = getBip32KeysFromSdkCore(bitgo, params, { requireBitGoXpub: true });
250415
if (!isTriple(keys)) {
251416
throw new Error(`expected key triple`);
252417
}
@@ -303,14 +468,8 @@ export async function backupKeyRecovery(
303468
throw new Error('feeRate must be a positive number');
304469
}
305470

306-
const isKrsRecovery = getIsKrsRecovery(params);
307-
const isUnsignedSweep = getIsUnsignedSweep(params);
308-
const responseTxFormat = !isKrsRecovery || params.krsProvider === 'keyternal' ? 'legacy' : 'psbt';
309-
310-
const krsProvider = isKrsRecovery ? getKrsProvider(coin, params.krsProvider) : undefined;
311-
312471
// check whether key material and password authenticate the users and return parent keys of all three keys of the wallet
313-
const keys = getBip32Privkeys(bitgo, params);
472+
const keys = getBip32Keys(bitgo, params);
314473
const walletKeys = fixedScriptWallet.RootWalletKeys.from({
315474
triple: keys,
316475
derivationPrefixes: [params.userKeyPath || 'm/0/0', 'm/0/0', 'm/0/0'],
@@ -345,94 +504,64 @@ export async function backupKeyRecovery(
345504
)
346505
).flat();
347506

348-
// Execute the queries and gather the unspents
349-
const totalInputAmount = unspentSum(unspents);
350-
if (totalInputAmount <= BigInt(0)) {
351-
throw new ErrorNoInputToRecover();
352-
}
353-
354-
const txInfo = {} as BackupKeyRecoveryTransansaction;
355-
const feePerByte: number =
507+
const feeRateSatVB =
356508
params.feeRate !== undefined
357509
? params.feeRate
358510
: await getRecoveryFeePerBytes(coin, { defaultValue: DEFAULT_RECOVERY_FEERATE_SAT_VBYTE_V2 });
359511

360-
txInfo.inputs =
361-
responseTxFormat === 'legacy'
362-
? unspents.map((u) => ({ ...u, value: Number(u.value), valueString: u.value.toString(), prevTx: undefined }))
363-
: undefined;
512+
// Calculate KRS fee if needed
513+
const userHasPrivateKey = hasPrivateKey(keys[0]);
514+
const backupHasPrivateKey = hasPrivateKey(keys[1]);
515+
const isKrsRecovery = params.krsProvider !== undefined && userHasPrivateKey && !backupHasPrivateKey;
364516

365517
let krsFee = BigInt(0);
518+
let krsFeeAddress: string | undefined;
519+
366520
if (isKrsRecovery && params.krsProvider) {
367521
try {
368522
krsFee = BigInt(await calculateFeeAmount(coin, { provider: params.krsProvider }));
369523
} catch (err) {
370524
// Don't let this error block the recovery -
371525
console.dir(err);
372526
}
373-
}
374527

375-
let krsFeeAddress: string | undefined;
376-
if (krsProvider && krsFee > BigInt(0)) {
377-
if (!krsProvider.feeAddresses) {
378-
throw new Error(`keyProvider must define feeAddresses`);
379-
}
528+
if (krsFee > BigInt(0)) {
529+
const krsProviderConfig = getKrsProvider(coin, params.krsProvider);
530+
if (!krsProviderConfig.feeAddresses) {
531+
throw new Error(`keyProvider must define feeAddresses`);
532+
}
380533

381-
krsFeeAddress = krsProvider.feeAddresses[coin.getChain()];
534+
krsFeeAddress = krsProviderConfig.feeAddresses[coin.getChain()];
382535

383-
if (!krsFeeAddress) {
384-
throw new Error('this KRS provider has not configured their fee structure yet - recovery cannot be completed');
536+
if (!krsFeeAddress) {
537+
throw new Error('this KRS provider has not configured their fee structure yet - recovery cannot be completed');
538+
}
385539
}
386540
}
387541

388-
let psbt = createBackupKeyRecoveryPsbt(coin.getChain(), walletKeys, unspents, {
389-
feeRateSatVB: feePerByte,
542+
// Build and sign PSBT
543+
const psbt = backupKeyRecoveryWithWalletUnspents(
544+
coin.name,
545+
{
546+
walletKeys,
547+
keys,
548+
recoveryDestination: params.recoveryDestination,
549+
feeRateSatVB,
550+
krsFee,
551+
krsFeeAddress,
552+
},
553+
unspents
554+
);
555+
556+
// Format the result
557+
return formatBackupKeyRecoveryResult(coin, psbt, {
558+
walletKeys,
559+
keys,
390560
recoveryDestination: params.recoveryDestination,
391-
keyRecoveryServiceFee: krsFee,
392-
keyRecoveryServiceFeeAddress: krsFeeAddress,
561+
krsProvider: params.krsProvider,
562+
backupKey: params.backupKey,
563+
unspents,
393564
});
394-
395-
if (isUnsignedSweep) {
396-
return {
397-
txHex: encodeTransaction(psbt).toString('hex'),
398-
txInfo: {},
399-
feeInfo: {},
400-
coin: coin.getChain(),
401-
};
402-
}
403-
404-
const rootWalletKeysWasm = fixedScriptWallet.RootWalletKeys.from(walletKeys);
405-
const replayProtection = { publicKeys: getReplayProtectionPubkeys(coin.name) };
406-
407-
// Sign with user key first
408-
psbt = signAndVerifyPsbt(psbt, keys[0], rootWalletKeysWasm, replayProtection);
409-
410-
if (isKrsRecovery) {
411-
// The KRS provider keyternal solely supports P2SH, P2WSH, and P2SH-P2WSH input script types.
412-
// It currently uses an outdated BitGoJS SDK, which relies on a legacy transaction builder for cosigning.
413-
// Unfortunately, upgrading the keyternal code presents challenges,
414-
// which hinders the integration of the latest BitGoJS SDK with PSBT signing support.
415-
txInfo.transactionHex =
416-
params.krsProvider === 'keyternal'
417-
? Buffer.from(psbt.getHalfSignedLegacyFormat()).toString('hex')
418-
: encodeTransaction(psbt).toString('hex');
419-
} else {
420-
// Sign with backup key
421-
psbt = signAndVerifyPsbt(psbt, keys[1], rootWalletKeysWasm, replayProtection);
422-
// Finalize and extract transaction
423-
psbt.finalizeAllInputs();
424-
txInfo.transactionHex = Buffer.from(psbt.extractTransaction().toBytes()).toString('hex');
425-
}
426-
427-
if (isKrsRecovery) {
428-
txInfo.coin = coin.getChain();
429-
txInfo.backupKey = params.backupKey;
430-
const recoveryAmount = getRecoveryAmount(psbt, walletKeys, params.recoveryDestination);
431-
txInfo.recoveryAmount = Number(recoveryAmount);
432-
txInfo.recoveryAmountString = recoveryAmount.toString();
433-
}
434-
435-
return txInfo;
436565
}
437566

438567
export interface BitGoV1Unspent {

modules/abstract-utxo/test/unit/fixtures/bch/recovery/backupKeyRecovery-fullSignedRecovery-customUserKeyPath-p2sh.json

Lines changed: 0 additions & 30 deletions
This file was deleted.

0 commit comments

Comments
 (0)