Skip to content

Commit 8d6c758

Browse files
OttoAllmendingerllm-git
andcommitted
fix(abstract-utxo): refactor backup key recovery psbt creation
Extract PSBT creation logic to separate functions for better maintainability and testability. Improves error handling for insufficient funds and adds proper type annotations for fee parameters. Issue: BTC-2891 Co-authored-by: llm-git <[email protected]>
1 parent ef2b442 commit 8d6c758

File tree

2 files changed

+104
-32
lines changed

2 files changed

+104
-32
lines changed

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

Lines changed: 12 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
import _ from 'lodash';
22
import * as utxolib from '@bitgo/utxo-lib';
3-
import { Dimensions } from '@bitgo/unspents';
43
import {
54
BitGoBase,
65
ErrorNoInputToRecover,
@@ -20,6 +19,7 @@ import { generateAddressWithChainAndIndex } from '../address';
2019
import { forCoin, RecoveryProvider } from './RecoveryProvider';
2120
import { MempoolApi } from './mempoolApi';
2221
import { CoingeckoApi } from './coingeckoApi';
22+
import { createBackupKeyRecoveryPsbt, getRecoveryAmount } from './psbt';
2323

2424
type ScriptType2Of3 = utxolib.bitgo.outputScripts.ScriptType2Of3;
2525
type ChainCode = utxolib.bitgo.ChainCode;
@@ -100,6 +100,7 @@ export interface RecoverParams {
100100
apiKey?: string;
101101
userKeyPath?: string;
102102
recoveryProvider?: RecoveryProvider;
103+
/** Satoshi per byte */
103104
feeRate?: number;
104105
}
105106

@@ -331,10 +332,6 @@ export async function backupKeyRecovery(
331332
throw new ErrorNoInputToRecover();
332333
}
333334

334-
// Build the psbt
335-
const psbt = utxolib.bitgo.createPsbtForNetwork({ network: coin.network });
336-
// xpubs can become handy for many things.
337-
utxolib.bitgo.addXpubsToPsbt(psbt, walletKeys);
338335
const txInfo = {} as BackupKeyRecoveryTransansaction;
339336
const feePerByte: number =
340337
params.feeRate !== undefined
@@ -346,10 +343,6 @@ export async function backupKeyRecovery(
346343
? unspents.map((u) => ({ ...u, value: Number(u.value), valueString: u.value.toString(), prevTx: undefined }))
347344
: undefined;
348345

349-
unspents.forEach((unspent) => {
350-
utxolib.bitgo.addWalletUnspentToPsbt(psbt, unspent, walletKeys, 'user', 'backup');
351-
});
352-
353346
let krsFee = BigInt(0);
354347
if (isKrsRecovery && params.krsProvider) {
355348
try {
@@ -360,40 +353,26 @@ export async function backupKeyRecovery(
360353
}
361354
}
362355

363-
const dimensions = Dimensions.fromPsbt(psbt)
364-
// Add the recovery output
365-
.plus(Dimensions.fromOutput({ script: utxolib.address.toOutputScript(params.recoveryDestination, coin.network) }))
366-
// KRS recovery transactions have a 2nd output to pay the recovery fee, like paygo fees.
367-
.plus(krsFee > BigInt(0) ? Dimensions.SingleOutput.p2wsh : Dimensions.ZERO);
368-
const approximateFee = BigInt(dimensions.getVSize() * feePerByte);
369-
370-
const recoveryAmount = totalInputAmount - approximateFee - krsFee;
371-
372-
if (recoveryAmount < BigInt(0)) {
373-
throw new Error(`this wallet\'s balance is too low to pay the fees specified by the KRS provider.
374-
Existing balance on wallet: ${totalInputAmount.toString()}. Estimated network fee for the recovery transaction
375-
: ${approximateFee.toString()}, KRS fee to pay: ${krsFee.toString()}. After deducting fees, your total
376-
recoverable balance is ${recoveryAmount.toString()}`);
377-
}
378-
379-
const recoveryOutputScript = utxolib.address.toOutputScript(params.recoveryDestination, coin.network);
380-
psbt.addOutput({ script: recoveryOutputScript, value: recoveryAmount });
381-
356+
let krsFeeAddress: string | undefined;
382357
if (krsProvider && krsFee > BigInt(0)) {
383358
if (!krsProvider.feeAddresses) {
384359
throw new Error(`keyProvider must define feeAddresses`);
385360
}
386361

387-
const krsFeeAddress = krsProvider.feeAddresses[coin.getChain()];
362+
krsFeeAddress = krsProvider.feeAddresses[coin.getChain()];
388363

389364
if (!krsFeeAddress) {
390365
throw new Error('this KRS provider has not configured their fee structure yet - recovery cannot be completed');
391366
}
392-
393-
const krsFeeOutputScript = utxolib.address.toOutputScript(krsFeeAddress, coin.network);
394-
psbt.addOutput({ script: krsFeeOutputScript, value: krsFee });
395367
}
396368

369+
const psbt = createBackupKeyRecoveryPsbt(coin.network, walletKeys, unspents, {
370+
feeRateSatVB: feePerByte,
371+
recoveryDestination: params.recoveryDestination,
372+
keyRecoveryServiceFee: krsFee,
373+
keyRecoveryServiceFeeAddress: krsFeeAddress,
374+
});
375+
397376
if (isUnsignedSweep) {
398377
return {
399378
txHex: psbt.toHex(),
@@ -421,6 +400,7 @@ export async function backupKeyRecovery(
421400
if (isKrsRecovery) {
422401
txInfo.coin = coin.getChain();
423402
txInfo.backupKey = params.backupKey;
403+
const recoveryAmount = getRecoveryAmount(psbt, params.recoveryDestination);
424404
txInfo.recoveryAmount = Number(recoveryAmount);
425405
txInfo.recoveryAmountString = recoveryAmount.toString();
426406
}
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
import * as utxolib from '@bitgo/utxo-lib';
2+
import { Dimensions } from '@bitgo/unspents';
3+
4+
type RootWalletKeys = utxolib.bitgo.RootWalletKeys;
5+
type WalletUnspent<TNumber extends number | bigint> = utxolib.bitgo.WalletUnspent<TNumber>;
6+
7+
class InsufficientFundsError extends Error {
8+
constructor(
9+
public totalInputAmount: bigint,
10+
public approximateFee: bigint,
11+
public krsFee: bigint,
12+
public recoveryAmount: bigint
13+
) {
14+
super(
15+
`This wallet's balance is too low to pay the fees specified by the KRS provider.` +
16+
`Existing balance on wallet: ${totalInputAmount.toString()}. ` +
17+
`Estimated network fee for the recovery transaction: ${approximateFee.toString()}` +
18+
`KRS fee to pay: ${krsFee.toString()}. ` +
19+
`After deducting fees, your total recoverable balance is ${recoveryAmount.toString()}`
20+
);
21+
}
22+
}
23+
24+
export function createBackupKeyRecoveryPsbt(
25+
network: utxolib.Network,
26+
rootWalletKeys: RootWalletKeys,
27+
unspents: WalletUnspent<bigint>[],
28+
{
29+
feeRateSatVB,
30+
recoveryDestination,
31+
keyRecoveryServiceFee,
32+
keyRecoveryServiceFeeAddress,
33+
}: {
34+
feeRateSatVB: number;
35+
recoveryDestination: string;
36+
keyRecoveryServiceFee: bigint;
37+
keyRecoveryServiceFeeAddress: string | undefined;
38+
}
39+
): utxolib.bitgo.UtxoPsbt {
40+
if (keyRecoveryServiceFee > 0 && !keyRecoveryServiceFeeAddress) {
41+
throw new Error('keyRecoveryServiceFeeAddress is required when keyRecoveryServiceFee is provided');
42+
}
43+
44+
const psbt = utxolib.bitgo.createPsbtForNetwork({ network });
45+
utxolib.bitgo.addXpubsToPsbt(psbt, rootWalletKeys);
46+
unspents.forEach((unspent) => {
47+
utxolib.bitgo.addWalletUnspentToPsbt(psbt, unspent, rootWalletKeys, 'user', 'backup');
48+
});
49+
50+
let dimensions = Dimensions.fromPsbt(psbt).plus(
51+
Dimensions.fromOutput({ script: utxolib.address.toOutputScript(recoveryDestination, network) })
52+
);
53+
54+
if (keyRecoveryServiceFeeAddress) {
55+
dimensions = dimensions.plus(
56+
Dimensions.fromOutput({
57+
script: utxolib.address.toOutputScript(keyRecoveryServiceFeeAddress, network),
58+
})
59+
);
60+
}
61+
62+
const approximateFee = BigInt(dimensions.getVSize() * feeRateSatVB);
63+
64+
const totalInputAmount = utxolib.bitgo.unspentSum(unspents, 'bigint');
65+
66+
const recoveryAmount = totalInputAmount - approximateFee - keyRecoveryServiceFee;
67+
68+
// FIXME(BTC-2650): we should check for dust limit here instead
69+
if (recoveryAmount < BigInt(0)) {
70+
throw new InsufficientFundsError(totalInputAmount, approximateFee, keyRecoveryServiceFee, recoveryAmount);
71+
}
72+
73+
psbt.addOutput({ script: utxolib.address.toOutputScript(recoveryDestination, network), value: recoveryAmount });
74+
75+
if (keyRecoveryServiceFeeAddress) {
76+
psbt.addOutput({
77+
script: utxolib.address.toOutputScript(keyRecoveryServiceFeeAddress, network),
78+
value: keyRecoveryServiceFee,
79+
});
80+
}
81+
82+
return psbt;
83+
}
84+
85+
export function getRecoveryAmount(psbt: utxolib.bitgo.UtxoPsbt, address: string): bigint {
86+
const recoveryOutputScript = utxolib.address.toOutputScript(address, psbt.network);
87+
const output = psbt.txOutputs.find((o) => o.script.equals(recoveryOutputScript));
88+
if (!output) {
89+
throw new Error(`Recovery destination output not found in PSBT: ${address}`);
90+
}
91+
return output.value;
92+
}

0 commit comments

Comments
 (0)