Skip to content

Commit d6a558a

Browse files
Merge pull request #7794 from BitGo/BTC-2891
feat: use wasm-utxo for PSBT creation in backupKeyRecovery
2 parents 19a4e23 + 5ec3b7e commit d6a558a

File tree

117 files changed

+408
-249
lines changed

Some content is hidden

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

117 files changed

+408
-249
lines changed

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

Lines changed: 176 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,41 @@
11
import * as utxolib from '@bitgo/utxo-lib';
22
import { Dimensions } from '@bitgo/unspents';
3+
import { fixedScriptWallet, utxolibCompat } from '@bitgo/wasm-utxo';
34

45
type RootWalletKeys = utxolib.bitgo.RootWalletKeys;
56
type WalletUnspent<TNumber extends number | bigint> = utxolib.bitgo.WalletUnspent<TNumber>;
67

8+
const { chainCodesP2tr, chainCodesP2trMusig2 } = utxolib.bitgo;
9+
10+
type ChainCode = utxolib.bitgo.ChainCode;
11+
12+
/**
13+
* Backend to use for PSBT creation.
14+
* - 'wasm-utxo': Use wasm-utxo for PSBT creation (default)
15+
* - 'utxolib': Use utxolib for PSBT creation (legacy)
16+
*/
17+
export type PsbtBackend = 'wasm-utxo' | 'utxolib';
18+
19+
/**
20+
* Check if a chain code is for a taproot script type
21+
*/
22+
function isTaprootChain(chain: ChainCode): boolean {
23+
return (
24+
(chainCodesP2tr as readonly number[]).includes(chain) || (chainCodesP2trMusig2 as readonly number[]).includes(chain)
25+
);
26+
}
27+
28+
/**
29+
* Convert utxolib Network to wasm-utxo network name
30+
*/
31+
function toNetworkName(network: utxolib.Network): utxolibCompat.UtxolibName {
32+
const networkName = utxolib.getNetworkName(network);
33+
if (!networkName) {
34+
throw new Error(`Invalid network`);
35+
}
36+
return networkName;
37+
}
38+
739
class InsufficientFundsError extends Error {
840
constructor(
941
public totalInputAmount: bigint,
@@ -21,25 +53,25 @@ class InsufficientFundsError extends Error {
2153
}
2254
}
2355

24-
export function createBackupKeyRecoveryPsbt(
56+
interface CreateBackupKeyRecoveryPsbtOptions {
57+
feeRateSatVB: number;
58+
recoveryDestination: string;
59+
keyRecoveryServiceFee: bigint;
60+
keyRecoveryServiceFeeAddress: string | undefined;
61+
/** Block height for Zcash networks (required to determine consensus branch ID) */
62+
blockHeight?: number;
63+
}
64+
65+
/**
66+
* Create a backup key recovery PSBT using utxolib (legacy implementation)
67+
*/
68+
function createBackupKeyRecoveryPsbtUtxolib(
2569
network: utxolib.Network,
2670
rootWalletKeys: RootWalletKeys,
2771
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-
}
72+
options: CreateBackupKeyRecoveryPsbtOptions
3973
): utxolib.bitgo.UtxoPsbt {
40-
if (keyRecoveryServiceFee > 0 && !keyRecoveryServiceFeeAddress) {
41-
throw new Error('keyRecoveryServiceFeeAddress is required when keyRecoveryServiceFee is provided');
42-
}
74+
const { feeRateSatVB, recoveryDestination, keyRecoveryServiceFee, keyRecoveryServiceFeeAddress } = options;
4375

4476
const psbt = utxolib.bitgo.createPsbtForNetwork({ network });
4577
utxolib.bitgo.addXpubsToPsbt(psbt, rootWalletKeys);
@@ -60,12 +92,112 @@ export function createBackupKeyRecoveryPsbt(
6092
}
6193

6294
const approximateFee = BigInt(dimensions.getVSize() * feeRateSatVB);
63-
6495
const totalInputAmount = utxolib.bitgo.unspentSum(unspents, 'bigint');
96+
const recoveryAmount = totalInputAmount - approximateFee - keyRecoveryServiceFee;
97+
98+
if (recoveryAmount < BigInt(0)) {
99+
throw new InsufficientFundsError(totalInputAmount, approximateFee, keyRecoveryServiceFee, recoveryAmount);
100+
}
101+
102+
psbt.addOutput({ script: utxolib.address.toOutputScript(recoveryDestination, network), value: recoveryAmount });
103+
104+
if (keyRecoveryServiceFeeAddress) {
105+
psbt.addOutput({
106+
script: utxolib.address.toOutputScript(keyRecoveryServiceFeeAddress, network),
107+
value: keyRecoveryServiceFee,
108+
});
109+
}
110+
111+
return psbt;
112+
}
113+
114+
/**
115+
* Check if the network is a Zcash network
116+
*/
117+
function isZcashNetwork(networkName: utxolibCompat.UtxolibName): boolean {
118+
return networkName === 'zcash' || networkName === 'zcashTest';
119+
}
120+
121+
/**
122+
* Default block heights for Zcash networks if not provided.
123+
* These should be set to a height after the latest network upgrade.
124+
* TODO(BTC-2901): get the height from blockchair API instead of hardcoding.
125+
*/
126+
const ZCASH_DEFAULT_BLOCK_HEIGHTS: Record<string, number> = {
127+
zcash: 3146400,
128+
zcashTest: 3536500,
129+
};
130+
131+
/**
132+
* Create a backup key recovery PSBT using wasm-utxo
133+
*/
134+
function createBackupKeyRecoveryPsbtWasm(
135+
network: utxolib.Network,
136+
rootWalletKeys: RootWalletKeys,
137+
unspents: WalletUnspent<bigint>[],
138+
options: CreateBackupKeyRecoveryPsbtOptions
139+
): utxolib.bitgo.UtxoPsbt {
140+
const { feeRateSatVB, recoveryDestination, keyRecoveryServiceFee, keyRecoveryServiceFeeAddress } = options;
141+
142+
const networkName = toNetworkName(network);
143+
144+
// Create PSBT with wasm-utxo and add wallet inputs
145+
// wasm-utxo's RootWalletKeys.from() accepts utxolib's RootWalletKeys format (IWalletKeys interface)
146+
let wasmPsbt: fixedScriptWallet.BitGoPsbt;
147+
148+
if (isZcashNetwork(networkName)) {
149+
// For Zcash, use ZcashBitGoPsbt which requires block height to determine consensus branch ID
150+
const blockHeight = options.blockHeight ?? ZCASH_DEFAULT_BLOCK_HEIGHTS[networkName];
151+
wasmPsbt = fixedScriptWallet.ZcashBitGoPsbt.createEmpty(networkName as 'zcash' | 'zcashTest', rootWalletKeys, {
152+
blockHeight,
153+
});
154+
} else {
155+
wasmPsbt = fixedScriptWallet.BitGoPsbt.createEmpty(networkName, rootWalletKeys);
156+
}
157+
158+
unspents.forEach((unspent) => {
159+
const { txid, vout } = utxolib.bitgo.parseOutputId(unspent.id);
160+
const signPath: fixedScriptWallet.SignPath | undefined = isTaprootChain(unspent.chain)
161+
? { signer: 'user', cosigner: 'backup' }
162+
: undefined;
163+
164+
// prevTx may be added dynamically in backupKeyRecovery for non-segwit inputs
165+
const prevTx = (unspent as WalletUnspent<bigint> & { prevTx?: Buffer }).prevTx;
65166

167+
wasmPsbt.addWalletInput(
168+
{
169+
txid,
170+
vout,
171+
value: unspent.value,
172+
prevTx: prevTx,
173+
},
174+
rootWalletKeys,
175+
{
176+
scriptId: { chain: unspent.chain, index: unspent.index },
177+
signPath,
178+
}
179+
);
180+
});
181+
182+
// Convert wasm-utxo PSBT to utxolib PSBT for dimension calculation and output addition
183+
const psbt = utxolib.bitgo.createPsbtFromBuffer(Buffer.from(wasmPsbt.serialize()), network);
184+
185+
let dimensions = Dimensions.fromPsbt(psbt).plus(
186+
Dimensions.fromOutput({ script: utxolib.address.toOutputScript(recoveryDestination, network) })
187+
);
188+
189+
if (keyRecoveryServiceFeeAddress) {
190+
dimensions = dimensions.plus(
191+
Dimensions.fromOutput({
192+
script: utxolib.address.toOutputScript(keyRecoveryServiceFeeAddress, network),
193+
})
194+
);
195+
}
196+
197+
const approximateFee = BigInt(dimensions.getVSize() * feeRateSatVB);
198+
const totalInputAmount = utxolib.bitgo.unspentSum(unspents, 'bigint');
66199
const recoveryAmount = totalInputAmount - approximateFee - keyRecoveryServiceFee;
67200

68-
// FIXME(BTC-2650): we should check for dust limit here instead
69201
if (recoveryAmount < BigInt(0)) {
70202
throw new InsufficientFundsError(totalInputAmount, approximateFee, keyRecoveryServiceFee, recoveryAmount);
71203
}
@@ -82,6 +214,33 @@ export function createBackupKeyRecoveryPsbt(
82214
return psbt;
83215
}
84216

217+
/**
218+
* Create a backup key recovery PSBT.
219+
*
220+
* @param network - The network for the PSBT
221+
* @param rootWalletKeys - The wallet keys
222+
* @param unspents - The unspents to include in the PSBT
223+
* @param options - Options for creating the PSBT
224+
* @param backend - Which backend to use for PSBT creation (default: 'wasm-utxo')
225+
*/
226+
export function createBackupKeyRecoveryPsbt(
227+
network: utxolib.Network,
228+
rootWalletKeys: RootWalletKeys,
229+
unspents: WalletUnspent<bigint>[],
230+
options: CreateBackupKeyRecoveryPsbtOptions,
231+
backend: PsbtBackend = 'wasm-utxo'
232+
): utxolib.bitgo.UtxoPsbt {
233+
if (options.keyRecoveryServiceFee > 0 && !options.keyRecoveryServiceFeeAddress) {
234+
throw new Error('keyRecoveryServiceFeeAddress is required when keyRecoveryServiceFee is provided');
235+
}
236+
237+
if (backend === 'wasm-utxo') {
238+
return createBackupKeyRecoveryPsbtWasm(network, rootWalletKeys, unspents, options);
239+
} else {
240+
return createBackupKeyRecoveryPsbtUtxolib(network, rootWalletKeys, unspents, options);
241+
}
242+
}
243+
85244
export function getRecoveryAmount(psbt: utxolib.bitgo.UtxoPsbt, address: string): bigint {
86245
const recoveryOutputScript = utxolib.address.toOutputScript(address, psbt.network);
87246
const output = psbt.txOutputs.find((o) => o.script.equals(recoveryOutputScript));

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,6 @@
2525
"valueString": "300000000"
2626
}
2727
],
28-
"transactionHex": "02000000038bb70cc33ba011c71d725c686a81b1f19d2955d204300f384838725f076edd9800000000fdfe0000483045022100e68d7057fdad1fd9e7da1dd0d2745600cd7ebc6b3bdfdc8c977c27f117dec1ee022014a862be7e83b092cea8c4791d47d9ea87bc3a7e4d7851fad30e9da0a8933efc41483045022100d4295855382edd094687ade706ccf51375c716e3acd2156cb0d7403f857a795f0220409c5b8f8ed66f43e563c2c4e401b8ca0cfab3c89452645c92c4010ee07d74d5414c69522102587d7749d1ed2a3d3d300b969e68cbedb042ea19e3ee90c4131e2092f5e1181e21030795af84ecc10252d8a894f54845beeb5624a1c24c3747cc654bd430539dee3521029b30ebe8eb23f8cec82f25a80e3b423979ec3ba1fe07d9d4ed9f6361258bc31d53aeffffffffeec68db14dcb1f1340f46c6f9d9a7303915ccd9022d59e7c9e13dae7c91ffe9b00000000fc004730440220487d165adcc526d5bf659e5dfec94e07c8eaa6567308d29a7b4676456e71288802204172d68f63bcc29141095b81a9366056b6d11260d86c6f1dfa8a154953b0a7854147304402205d3c5b6105a2fa1819973ef6b83c1575468be0bce6757992b365583c11690fa902200134cc5b58d6590664f45e797990334b4fa989b21ef2ec5194a9d3ae262855ad414c69522102587d7749d1ed2a3d3d300b969e68cbedb042ea19e3ee90c4131e2092f5e1181e21030795af84ecc10252d8a894f54845beeb5624a1c24c3747cc654bd430539dee3521029b30ebe8eb23f8cec82f25a80e3b423979ec3ba1fe07d9d4ed9f6361258bc31d53aeffffffffe3ad05836997543b105275534081e8eac637e0e4d2a5bc544d7b0d22f32d49de00000000fc00473044022025d60881a0bf878533362094e8a531f1a066fa2f85ac92d5965f20d7227682c20220685efc33bb4e3a81963f4ebd0a18ec088db96f20432e1c943228e2c1fff2996141473044022065fb4062083c3cbf12638cf087b36512d22458cdf76e5f92582992885efab050022039885486cc1b912d0843cf8227a7473b5938c8927a9b7f41efd03af87752fffe414c69522102587d7749d1ed2a3d3d300b969e68cbedb042ea19e3ee90c4131e2092f5e1181e21030795af84ecc10252d8a894f54845beeb5624a1c24c3747cc654bd430539dee3521029b30ebe8eb23f8cec82f25a80e3b423979ec3ba1fe07d9d4ed9f6361258bc31d53aeffffffff0160d8c1230000000017a91439c65a0d0072a140694d6b13ec5f5f2437de99ff8700000000",
29-
"txid": "b50d92e5be1c143941ad3ce4aa176c69c6299cd4c689d5caceeff5f943f8ddb3"
28+
"transactionHex": "02000000038bb70cc33ba011c71d725c686a81b1f19d2955d204300f384838725f076edd9800000000fc004730440220771891dd2e048fd1a669e67bf3f4bf2757a45a2dac2d3292065e983d04e8f8ac0220345dd6287ff1d4d1b678f32033d57475e4b0e320386c07a1537ebda7b759ff074147304402206b63d74b9267fbb680bc950efaaccb0cece431f1fd4186ddd750765c8024d15a022010ebc2e74241e6e3cca7fba79e5dc63dce28e19278ee2880b4815ce9da89a0c4414c69522102587d7749d1ed2a3d3d300b969e68cbedb042ea19e3ee90c4131e2092f5e1181e21030795af84ecc10252d8a894f54845beeb5624a1c24c3747cc654bd430539dee3521029b30ebe8eb23f8cec82f25a80e3b423979ec3ba1fe07d9d4ed9f6361258bc31d53aefeffffffeec68db14dcb1f1340f46c6f9d9a7303915ccd9022d59e7c9e13dae7c91ffe9b00000000fdfe0000483045022100f6310f1da22508f5a68dfe19f80c6cd2a907045044f4a51f22197fea821daac50220391b37d34e90c07288e544e69d0daa99b746248546d4b6a7e1a71b8bc4a429bb41483045022100ca7874530ed307533abee2431621f170cd22b5db93c1d0f713630e0a75a5ce8702202306c85f3173b1e4ef018e2eee82e44fe491f1e97e9771b446f638158e15d38c414c69522102587d7749d1ed2a3d3d300b969e68cbedb042ea19e3ee90c4131e2092f5e1181e21030795af84ecc10252d8a894f54845beeb5624a1c24c3747cc654bd430539dee3521029b30ebe8eb23f8cec82f25a80e3b423979ec3ba1fe07d9d4ed9f6361258bc31d53aefeffffffe3ad05836997543b105275534081e8eac637e0e4d2a5bc544d7b0d22f32d49de00000000fc00473044022051ee088819c0fa8434cb90cd7b3350ab02c29dbe46d7e615cac643ec802859af02201904aa96f82d7c0718d7c531bc76c3c927ac63f6bb7f4e4b490a84e41f34d58f41473044022013c930583d9d5cecc915a1209e46a79e3da72bf8a0efbc8d2236bdcdf63810c6022029f53b58d20ea73a580142caddc957c41b35789a37203d8fa86871264648fb7f414c69522102587d7749d1ed2a3d3d300b969e68cbedb042ea19e3ee90c4131e2092f5e1181e21030795af84ecc10252d8a894f54845beeb5624a1c24c3747cc654bd430539dee3521029b30ebe8eb23f8cec82f25a80e3b423979ec3ba1fe07d9d4ed9f6361258bc31d53aefeffffff0160d8c1230000000017a91439c65a0d0072a140694d6b13ec5f5f2437de99ff8700000000",
29+
"txid": "f0698c5a35a7f41fd8a5370033d63d11eab60d126bd08e6324b38cd79536bb95"
3030
}

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,6 @@
2525
"valueString": "300000000"
2626
}
2727
],
28-
"transactionHex": "0200000003d7c3d565c03d67573680c96285939855cc656beade6793cbb121fb2ca51f339c00000000fc0047304402205c2c110269e115e52d5afbbc33c4cb8407f391156e19003307b8e440d724092b02206054509b229ad6c03a59a3a7eb6a06e915873125082e677b23b42cb23ed4e3b84147304402201ecb68fe27d7ad76562bf0bda73d185f795df711a179a7ea4e26f9578da872720220044397e408c7d3788f02b688b5a7de1f43fbbefe1959d20926a52d189306de29414c69522102d06c08ccf0fddefff881e869b951d4b92e936118b3360182c5b8c55f4c40bc6121030795af84ecc10252d8a894f54845beeb5624a1c24c3747cc654bd430539dee3521029b30ebe8eb23f8cec82f25a80e3b423979ec3ba1fe07d9d4ed9f6361258bc31d53aeffffffff6adf8f092a117182d89effd08726812693d051e2c6e78efc3b967f88a619f21600000000fdfe00004830450221009490a4723a5f83f076ce847161a3a8f7fe1b88ee222aa203b8002b43366ddbd602204a88daa818f63ca9475690386d09e7b44c90a2382a9d5048ccdeaaf663ca069241483045022100e6acf24d06227e8348d2303029e0602194ae4c8085ce572fe9ad9c6aab251f9602203797527deb5a14663d87719344ac205251f52ef6435bb6bf4ff5b185e6921243414c69522102d06c08ccf0fddefff881e869b951d4b92e936118b3360182c5b8c55f4c40bc6121030795af84ecc10252d8a894f54845beeb5624a1c24c3747cc654bd430539dee3521029b30ebe8eb23f8cec82f25a80e3b423979ec3ba1fe07d9d4ed9f6361258bc31d53aeffffffff3f482a856356853fe1e8cefffc23e30363038ba4c8c89bc505e4c5f0f595158c00000000fdfe00004830450221009e0f3204f6c3829ac91eb3f721a45a169af96a17ba23f89d20e76ba44a828c530220563fbafd634e4672163cafb4c2982ba69e290db7c10d366557c77f8e2216131241483045022100efcfbb07e483a105e8020940ca6d3139249f354d309f0548ecadfec877e4c05a02204b197f099abd4211e7f17a21c7a303435d12eeba39f17ff5fd441fe60f09bb15414c69522102d06c08ccf0fddefff881e869b951d4b92e936118b3360182c5b8c55f4c40bc6121030795af84ecc10252d8a894f54845beeb5624a1c24c3747cc654bd430539dee3521029b30ebe8eb23f8cec82f25a80e3b423979ec3ba1fe07d9d4ed9f6361258bc31d53aeffffffff0160d8c1230000000017a91439c65a0d0072a140694d6b13ec5f5f2437de99ff8700000000",
29-
"txid": "3802e1ad47c85bbf65ed794641a1f2f06755f1569c3d69cd5e54db9abe9d579d"
28+
"transactionHex": "0200000003d7c3d565c03d67573680c96285939855cc656beade6793cbb121fb2ca51f339c00000000fdfe0000483045022100a33bf5f340dc66dec2e4fb02aa79d4bf95a77aac005f98b9854c7d6ef4ea70d902207c962c0a919eafe81ff101925d8c73626780ea590de7dc2aaae67a9562604cab4148304502210088eaa78afc86a60f57b8a7d40519a5466114e8fa96060828795d3805d188169002207669911b9c7c3eb2ee1fc71cc51ce79bf7ba3e8f2f7e29ac24abf2845e1ca836414c69522102d06c08ccf0fddefff881e869b951d4b92e936118b3360182c5b8c55f4c40bc6121030795af84ecc10252d8a894f54845beeb5624a1c24c3747cc654bd430539dee3521029b30ebe8eb23f8cec82f25a80e3b423979ec3ba1fe07d9d4ed9f6361258bc31d53aefeffffff6adf8f092a117182d89effd08726812693d051e2c6e78efc3b967f88a619f21600000000fc0047304402204e2c95c0ef4fc6e49c410edd45be4a37e36fc5a477f8139ca339b7aa27c85584022023fd2628e3ea7f48c25df8373b7bef8b3efe37a8e22d3ccaeac20c97e2a51a0d41473044022045cdab5f30ec5ba583ddc9fe2f8b481bbe9290c21e3000d981863ef1a360cbad02205472be823b9972c14a81805353af3a7633187ea6c800ddfad970479275713e2a414c69522102d06c08ccf0fddefff881e869b951d4b92e936118b3360182c5b8c55f4c40bc6121030795af84ecc10252d8a894f54845beeb5624a1c24c3747cc654bd430539dee3521029b30ebe8eb23f8cec82f25a80e3b423979ec3ba1fe07d9d4ed9f6361258bc31d53aefeffffff3f482a856356853fe1e8cefffc23e30363038ba4c8c89bc505e4c5f0f595158c00000000fdfd0000483045022100be8b82b9e941fea70a8ecc0bbf629936a63958fa82c232e4c6e81b56ec4ec4e60220796d7d1f79eea8e51d02e445be461763c416192710c5e04f491aefa48d4a7241414730440220156ae4630757612790f8722e7598829f80271798ca98ec17fcf8d7c6d0114eff022064fe920fdd9eefb3b9a2a566f825494efd4a02c1f6ba9f2815a1462fb4585299414c69522102d06c08ccf0fddefff881e869b951d4b92e936118b3360182c5b8c55f4c40bc6121030795af84ecc10252d8a894f54845beeb5624a1c24c3747cc654bd430539dee3521029b30ebe8eb23f8cec82f25a80e3b423979ec3ba1fe07d9d4ed9f6361258bc31d53aefeffffff0160d8c1230000000017a91439c65a0d0072a140694d6b13ec5f5f2437de99ff8700000000",
29+
"txid": "c02169878a8a0c8b8ef1c61dabf4dd49af67d5d66b9928c454268812b40a7eb6"
3030
}

0 commit comments

Comments
 (0)