Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
193 changes: 176 additions & 17 deletions modules/abstract-utxo/src/recovery/psbt.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,41 @@
import * as utxolib from '@bitgo/utxo-lib';
import { Dimensions } from '@bitgo/unspents';
import { fixedScriptWallet, utxolibCompat } from '@bitgo/wasm-utxo';

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

const { chainCodesP2tr, chainCodesP2trMusig2 } = utxolib.bitgo;

type ChainCode = utxolib.bitgo.ChainCode;

/**
* Backend to use for PSBT creation.
* - 'wasm-utxo': Use wasm-utxo for PSBT creation (default)
* - 'utxolib': Use utxolib for PSBT creation (legacy)
*/
export type PsbtBackend = 'wasm-utxo' | 'utxolib';

/**
* Check if a chain code is for a taproot script type
*/
function isTaprootChain(chain: ChainCode): boolean {
return (
(chainCodesP2tr as readonly number[]).includes(chain) || (chainCodesP2trMusig2 as readonly number[]).includes(chain)
);
}

/**
* Convert utxolib Network to wasm-utxo network name
*/
function toNetworkName(network: utxolib.Network): utxolibCompat.UtxolibName {
const networkName = utxolib.getNetworkName(network);
if (!networkName) {
throw new Error(`Invalid network`);
}
return networkName;
}

class InsufficientFundsError extends Error {
constructor(
public totalInputAmount: bigint,
Expand All @@ -21,25 +53,25 @@ class InsufficientFundsError extends Error {
}
}

export function createBackupKeyRecoveryPsbt(
interface CreateBackupKeyRecoveryPsbtOptions {
feeRateSatVB: number;
recoveryDestination: string;
keyRecoveryServiceFee: bigint;
keyRecoveryServiceFeeAddress: string | undefined;
/** Block height for Zcash networks (required to determine consensus branch ID) */
blockHeight?: number;
}

/**
* Create a backup key recovery PSBT using utxolib (legacy implementation)
*/
function createBackupKeyRecoveryPsbtUtxolib(
network: utxolib.Network,
rootWalletKeys: RootWalletKeys,
unspents: WalletUnspent<bigint>[],
{
feeRateSatVB,
recoveryDestination,
keyRecoveryServiceFee,
keyRecoveryServiceFeeAddress,
}: {
feeRateSatVB: number;
recoveryDestination: string;
keyRecoveryServiceFee: bigint;
keyRecoveryServiceFeeAddress: string | undefined;
}
options: CreateBackupKeyRecoveryPsbtOptions
): utxolib.bitgo.UtxoPsbt {
if (keyRecoveryServiceFee > 0 && !keyRecoveryServiceFeeAddress) {
throw new Error('keyRecoveryServiceFeeAddress is required when keyRecoveryServiceFee is provided');
}
const { feeRateSatVB, recoveryDestination, keyRecoveryServiceFee, keyRecoveryServiceFeeAddress } = options;

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

const approximateFee = BigInt(dimensions.getVSize() * feeRateSatVB);

const totalInputAmount = utxolib.bitgo.unspentSum(unspents, 'bigint');
const recoveryAmount = totalInputAmount - approximateFee - keyRecoveryServiceFee;

if (recoveryAmount < BigInt(0)) {
throw new InsufficientFundsError(totalInputAmount, approximateFee, keyRecoveryServiceFee, recoveryAmount);
}

psbt.addOutput({ script: utxolib.address.toOutputScript(recoveryDestination, network), value: recoveryAmount });

if (keyRecoveryServiceFeeAddress) {
psbt.addOutput({
script: utxolib.address.toOutputScript(keyRecoveryServiceFeeAddress, network),
value: keyRecoveryServiceFee,
});
}

return psbt;
}

/**
* Check if the network is a Zcash network
*/
function isZcashNetwork(networkName: utxolibCompat.UtxolibName): boolean {
return networkName === 'zcash' || networkName === 'zcashTest';
}

/**
* Default block heights for Zcash networks if not provided.
* These should be set to a height after the latest network upgrade.
* TODO(BTC-2901): get the height from blockchair API instead of hardcoding.
*/
const ZCASH_DEFAULT_BLOCK_HEIGHTS: Record<string, number> = {
zcash: 3146400,
zcashTest: 3536500,
};

/**
* Create a backup key recovery PSBT using wasm-utxo
*/
function createBackupKeyRecoveryPsbtWasm(
network: utxolib.Network,
rootWalletKeys: RootWalletKeys,
unspents: WalletUnspent<bigint>[],
options: CreateBackupKeyRecoveryPsbtOptions
): utxolib.bitgo.UtxoPsbt {
const { feeRateSatVB, recoveryDestination, keyRecoveryServiceFee, keyRecoveryServiceFeeAddress } = options;

const networkName = toNetworkName(network);

// Create PSBT with wasm-utxo and add wallet inputs
// wasm-utxo's RootWalletKeys.from() accepts utxolib's RootWalletKeys format (IWalletKeys interface)
let wasmPsbt: fixedScriptWallet.BitGoPsbt;

if (isZcashNetwork(networkName)) {
// For Zcash, use ZcashBitGoPsbt which requires block height to determine consensus branch ID
const blockHeight = options.blockHeight ?? ZCASH_DEFAULT_BLOCK_HEIGHTS[networkName];
wasmPsbt = fixedScriptWallet.ZcashBitGoPsbt.createEmpty(networkName as 'zcash' | 'zcashTest', rootWalletKeys, {
blockHeight,
});
} else {
wasmPsbt = fixedScriptWallet.BitGoPsbt.createEmpty(networkName, rootWalletKeys);
}

unspents.forEach((unspent) => {
const { txid, vout } = utxolib.bitgo.parseOutputId(unspent.id);
const signPath: fixedScriptWallet.SignPath | undefined = isTaprootChain(unspent.chain)
? { signer: 'user', cosigner: 'backup' }
: undefined;

// prevTx may be added dynamically in backupKeyRecovery for non-segwit inputs
const prevTx = (unspent as WalletUnspent<bigint> & { prevTx?: Buffer }).prevTx;

wasmPsbt.addWalletInput(
{
txid,
vout,
value: unspent.value,
prevTx: prevTx,
},
rootWalletKeys,
{
scriptId: { chain: unspent.chain, index: unspent.index },
signPath,
}
);
});

// Convert wasm-utxo PSBT to utxolib PSBT for dimension calculation and output addition
const psbt = utxolib.bitgo.createPsbtFromBuffer(Buffer.from(wasmPsbt.serialize()), network);

let dimensions = Dimensions.fromPsbt(psbt).plus(
Dimensions.fromOutput({ script: utxolib.address.toOutputScript(recoveryDestination, network) })
);

if (keyRecoveryServiceFeeAddress) {
dimensions = dimensions.plus(
Dimensions.fromOutput({
script: utxolib.address.toOutputScript(keyRecoveryServiceFeeAddress, network),
})
);
}
Comment on lines +182 to +195
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

TODO: add some sort of Dimensions support for wasm-utxo


const approximateFee = BigInt(dimensions.getVSize() * feeRateSatVB);
const totalInputAmount = utxolib.bitgo.unspentSum(unspents, 'bigint');
const recoveryAmount = totalInputAmount - approximateFee - keyRecoveryServiceFee;

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

/**
* Create a backup key recovery PSBT.
*
* @param network - The network for the PSBT
* @param rootWalletKeys - The wallet keys
* @param unspents - The unspents to include in the PSBT
* @param options - Options for creating the PSBT
* @param backend - Which backend to use for PSBT creation (default: 'wasm-utxo')
*/
export function createBackupKeyRecoveryPsbt(
network: utxolib.Network,
rootWalletKeys: RootWalletKeys,
unspents: WalletUnspent<bigint>[],
options: CreateBackupKeyRecoveryPsbtOptions,
backend: PsbtBackend = 'wasm-utxo'
): utxolib.bitgo.UtxoPsbt {
if (options.keyRecoveryServiceFee > 0 && !options.keyRecoveryServiceFeeAddress) {
throw new Error('keyRecoveryServiceFeeAddress is required when keyRecoveryServiceFee is provided');
}

if (backend === 'wasm-utxo') {
return createBackupKeyRecoveryPsbtWasm(network, rootWalletKeys, unspents, options);
} else {
return createBackupKeyRecoveryPsbtUtxolib(network, rootWalletKeys, unspents, options);
}
}

export function getRecoveryAmount(psbt: utxolib.bitgo.UtxoPsbt, address: string): bigint {
const recoveryOutputScript = utxolib.address.toOutputScript(address, psbt.network);
const output = psbt.txOutputs.find((o) => o.script.equals(recoveryOutputScript));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,6 @@
"valueString": "300000000"
}
],
"transactionHex": "02000000038bb70cc33ba011c71d725c686a81b1f19d2955d204300f384838725f076edd9800000000fdfe0000483045022100e68d7057fdad1fd9e7da1dd0d2745600cd7ebc6b3bdfdc8c977c27f117dec1ee022014a862be7e83b092cea8c4791d47d9ea87bc3a7e4d7851fad30e9da0a8933efc41483045022100d4295855382edd094687ade706ccf51375c716e3acd2156cb0d7403f857a795f0220409c5b8f8ed66f43e563c2c4e401b8ca0cfab3c89452645c92c4010ee07d74d5414c69522102587d7749d1ed2a3d3d300b969e68cbedb042ea19e3ee90c4131e2092f5e1181e21030795af84ecc10252d8a894f54845beeb5624a1c24c3747cc654bd430539dee3521029b30ebe8eb23f8cec82f25a80e3b423979ec3ba1fe07d9d4ed9f6361258bc31d53aeffffffffeec68db14dcb1f1340f46c6f9d9a7303915ccd9022d59e7c9e13dae7c91ffe9b00000000fc004730440220487d165adcc526d5bf659e5dfec94e07c8eaa6567308d29a7b4676456e71288802204172d68f63bcc29141095b81a9366056b6d11260d86c6f1dfa8a154953b0a7854147304402205d3c5b6105a2fa1819973ef6b83c1575468be0bce6757992b365583c11690fa902200134cc5b58d6590664f45e797990334b4fa989b21ef2ec5194a9d3ae262855ad414c69522102587d7749d1ed2a3d3d300b969e68cbedb042ea19e3ee90c4131e2092f5e1181e21030795af84ecc10252d8a894f54845beeb5624a1c24c3747cc654bd430539dee3521029b30ebe8eb23f8cec82f25a80e3b423979ec3ba1fe07d9d4ed9f6361258bc31d53aeffffffffe3ad05836997543b105275534081e8eac637e0e4d2a5bc544d7b0d22f32d49de00000000fc00473044022025d60881a0bf878533362094e8a531f1a066fa2f85ac92d5965f20d7227682c20220685efc33bb4e3a81963f4ebd0a18ec088db96f20432e1c943228e2c1fff2996141473044022065fb4062083c3cbf12638cf087b36512d22458cdf76e5f92582992885efab050022039885486cc1b912d0843cf8227a7473b5938c8927a9b7f41efd03af87752fffe414c69522102587d7749d1ed2a3d3d300b969e68cbedb042ea19e3ee90c4131e2092f5e1181e21030795af84ecc10252d8a894f54845beeb5624a1c24c3747cc654bd430539dee3521029b30ebe8eb23f8cec82f25a80e3b423979ec3ba1fe07d9d4ed9f6361258bc31d53aeffffffff0160d8c1230000000017a91439c65a0d0072a140694d6b13ec5f5f2437de99ff8700000000",
"txid": "b50d92e5be1c143941ad3ce4aa176c69c6299cd4c689d5caceeff5f943f8ddb3"
"transactionHex": "02000000038bb70cc33ba011c71d725c686a81b1f19d2955d204300f384838725f076edd9800000000fc004730440220771891dd2e048fd1a669e67bf3f4bf2757a45a2dac2d3292065e983d04e8f8ac0220345dd6287ff1d4d1b678f32033d57475e4b0e320386c07a1537ebda7b759ff074147304402206b63d74b9267fbb680bc950efaaccb0cece431f1fd4186ddd750765c8024d15a022010ebc2e74241e6e3cca7fba79e5dc63dce28e19278ee2880b4815ce9da89a0c4414c69522102587d7749d1ed2a3d3d300b969e68cbedb042ea19e3ee90c4131e2092f5e1181e21030795af84ecc10252d8a894f54845beeb5624a1c24c3747cc654bd430539dee3521029b30ebe8eb23f8cec82f25a80e3b423979ec3ba1fe07d9d4ed9f6361258bc31d53aefeffffffeec68db14dcb1f1340f46c6f9d9a7303915ccd9022d59e7c9e13dae7c91ffe9b00000000fdfe0000483045022100f6310f1da22508f5a68dfe19f80c6cd2a907045044f4a51f22197fea821daac50220391b37d34e90c07288e544e69d0daa99b746248546d4b6a7e1a71b8bc4a429bb41483045022100ca7874530ed307533abee2431621f170cd22b5db93c1d0f713630e0a75a5ce8702202306c85f3173b1e4ef018e2eee82e44fe491f1e97e9771b446f638158e15d38c414c69522102587d7749d1ed2a3d3d300b969e68cbedb042ea19e3ee90c4131e2092f5e1181e21030795af84ecc10252d8a894f54845beeb5624a1c24c3747cc654bd430539dee3521029b30ebe8eb23f8cec82f25a80e3b423979ec3ba1fe07d9d4ed9f6361258bc31d53aefeffffffe3ad05836997543b105275534081e8eac637e0e4d2a5bc544d7b0d22f32d49de00000000fc00473044022051ee088819c0fa8434cb90cd7b3350ab02c29dbe46d7e615cac643ec802859af02201904aa96f82d7c0718d7c531bc76c3c927ac63f6bb7f4e4b490a84e41f34d58f41473044022013c930583d9d5cecc915a1209e46a79e3da72bf8a0efbc8d2236bdcdf63810c6022029f53b58d20ea73a580142caddc957c41b35789a37203d8fa86871264648fb7f414c69522102587d7749d1ed2a3d3d300b969e68cbedb042ea19e3ee90c4131e2092f5e1181e21030795af84ecc10252d8a894f54845beeb5624a1c24c3747cc654bd430539dee3521029b30ebe8eb23f8cec82f25a80e3b423979ec3ba1fe07d9d4ed9f6361258bc31d53aefeffffff0160d8c1230000000017a91439c65a0d0072a140694d6b13ec5f5f2437de99ff8700000000",
"txid": "f0698c5a35a7f41fd8a5370033d63d11eab60d126bd08e6324b38cd79536bb95"
}
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,6 @@
"valueString": "300000000"
}
],
"transactionHex": "0200000003d7c3d565c03d67573680c96285939855cc656beade6793cbb121fb2ca51f339c00000000fc0047304402205c2c110269e115e52d5afbbc33c4cb8407f391156e19003307b8e440d724092b02206054509b229ad6c03a59a3a7eb6a06e915873125082e677b23b42cb23ed4e3b84147304402201ecb68fe27d7ad76562bf0bda73d185f795df711a179a7ea4e26f9578da872720220044397e408c7d3788f02b688b5a7de1f43fbbefe1959d20926a52d189306de29414c69522102d06c08ccf0fddefff881e869b951d4b92e936118b3360182c5b8c55f4c40bc6121030795af84ecc10252d8a894f54845beeb5624a1c24c3747cc654bd430539dee3521029b30ebe8eb23f8cec82f25a80e3b423979ec3ba1fe07d9d4ed9f6361258bc31d53aeffffffff6adf8f092a117182d89effd08726812693d051e2c6e78efc3b967f88a619f21600000000fdfe00004830450221009490a4723a5f83f076ce847161a3a8f7fe1b88ee222aa203b8002b43366ddbd602204a88daa818f63ca9475690386d09e7b44c90a2382a9d5048ccdeaaf663ca069241483045022100e6acf24d06227e8348d2303029e0602194ae4c8085ce572fe9ad9c6aab251f9602203797527deb5a14663d87719344ac205251f52ef6435bb6bf4ff5b185e6921243414c69522102d06c08ccf0fddefff881e869b951d4b92e936118b3360182c5b8c55f4c40bc6121030795af84ecc10252d8a894f54845beeb5624a1c24c3747cc654bd430539dee3521029b30ebe8eb23f8cec82f25a80e3b423979ec3ba1fe07d9d4ed9f6361258bc31d53aeffffffff3f482a856356853fe1e8cefffc23e30363038ba4c8c89bc505e4c5f0f595158c00000000fdfe00004830450221009e0f3204f6c3829ac91eb3f721a45a169af96a17ba23f89d20e76ba44a828c530220563fbafd634e4672163cafb4c2982ba69e290db7c10d366557c77f8e2216131241483045022100efcfbb07e483a105e8020940ca6d3139249f354d309f0548ecadfec877e4c05a02204b197f099abd4211e7f17a21c7a303435d12eeba39f17ff5fd441fe60f09bb15414c69522102d06c08ccf0fddefff881e869b951d4b92e936118b3360182c5b8c55f4c40bc6121030795af84ecc10252d8a894f54845beeb5624a1c24c3747cc654bd430539dee3521029b30ebe8eb23f8cec82f25a80e3b423979ec3ba1fe07d9d4ed9f6361258bc31d53aeffffffff0160d8c1230000000017a91439c65a0d0072a140694d6b13ec5f5f2437de99ff8700000000",
"txid": "3802e1ad47c85bbf65ed794641a1f2f06755f1569c3d69cd5e54db9abe9d579d"
"transactionHex": "0200000003d7c3d565c03d67573680c96285939855cc656beade6793cbb121fb2ca51f339c00000000fdfe0000483045022100a33bf5f340dc66dec2e4fb02aa79d4bf95a77aac005f98b9854c7d6ef4ea70d902207c962c0a919eafe81ff101925d8c73626780ea590de7dc2aaae67a9562604cab4148304502210088eaa78afc86a60f57b8a7d40519a5466114e8fa96060828795d3805d188169002207669911b9c7c3eb2ee1fc71cc51ce79bf7ba3e8f2f7e29ac24abf2845e1ca836414c69522102d06c08ccf0fddefff881e869b951d4b92e936118b3360182c5b8c55f4c40bc6121030795af84ecc10252d8a894f54845beeb5624a1c24c3747cc654bd430539dee3521029b30ebe8eb23f8cec82f25a80e3b423979ec3ba1fe07d9d4ed9f6361258bc31d53aefeffffff6adf8f092a117182d89effd08726812693d051e2c6e78efc3b967f88a619f21600000000fc0047304402204e2c95c0ef4fc6e49c410edd45be4a37e36fc5a477f8139ca339b7aa27c85584022023fd2628e3ea7f48c25df8373b7bef8b3efe37a8e22d3ccaeac20c97e2a51a0d41473044022045cdab5f30ec5ba583ddc9fe2f8b481bbe9290c21e3000d981863ef1a360cbad02205472be823b9972c14a81805353af3a7633187ea6c800ddfad970479275713e2a414c69522102d06c08ccf0fddefff881e869b951d4b92e936118b3360182c5b8c55f4c40bc6121030795af84ecc10252d8a894f54845beeb5624a1c24c3747cc654bd430539dee3521029b30ebe8eb23f8cec82f25a80e3b423979ec3ba1fe07d9d4ed9f6361258bc31d53aefeffffff3f482a856356853fe1e8cefffc23e30363038ba4c8c89bc505e4c5f0f595158c00000000fdfd0000483045022100be8b82b9e941fea70a8ecc0bbf629936a63958fa82c232e4c6e81b56ec4ec4e60220796d7d1f79eea8e51d02e445be461763c416192710c5e04f491aefa48d4a7241414730440220156ae4630757612790f8722e7598829f80271798ca98ec17fcf8d7c6d0114eff022064fe920fdd9eefb3b9a2a566f825494efd4a02c1f6ba9f2815a1462fb4585299414c69522102d06c08ccf0fddefff881e869b951d4b92e936118b3360182c5b8c55f4c40bc6121030795af84ecc10252d8a894f54845beeb5624a1c24c3747cc654bd430539dee3521029b30ebe8eb23f8cec82f25a80e3b423979ec3ba1fe07d9d4ed9f6361258bc31d53aefeffffff0160d8c1230000000017a91439c65a0d0072a140694d6b13ec5f5f2437de99ff8700000000",
"txid": "c02169878a8a0c8b8ef1c61dabf4dd49af67d5d66b9928c454268812b40a7eb6"
}
Loading