|
3 | 3 | BitGoBase, |
4 | 4 | ErrorNoInputToRecover, |
5 | 5 | getKrsProvider, |
6 | | - getBip32Keys, |
7 | | - getIsKrsRecovery, |
8 | | - getIsUnsignedSweep, |
| 6 | + getBip32Keys as getBip32KeysFromSdkCore, |
9 | 7 | isTriple, |
10 | 8 | krsProviders, |
11 | 9 | Triple, |
@@ -245,8 +243,175 @@ export type BackupKeyRecoveryTransansaction = { |
245 | 243 | recoveryAmountString: string; |
246 | 244 | }; |
247 | 245 |
|
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 }); |
250 | 415 | if (!isTriple(keys)) { |
251 | 416 | throw new Error(`expected key triple`); |
252 | 417 | } |
@@ -303,14 +468,8 @@ export async function backupKeyRecovery( |
303 | 468 | throw new Error('feeRate must be a positive number'); |
304 | 469 | } |
305 | 470 |
|
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 | | - |
312 | 471 | // 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); |
314 | 473 | const walletKeys = fixedScriptWallet.RootWalletKeys.from({ |
315 | 474 | triple: keys, |
316 | 475 | derivationPrefixes: [params.userKeyPath || 'm/0/0', 'm/0/0', 'm/0/0'], |
@@ -345,94 +504,64 @@ export async function backupKeyRecovery( |
345 | 504 | ) |
346 | 505 | ).flat(); |
347 | 506 |
|
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 = |
356 | 508 | params.feeRate !== undefined |
357 | 509 | ? params.feeRate |
358 | 510 | : await getRecoveryFeePerBytes(coin, { defaultValue: DEFAULT_RECOVERY_FEERATE_SAT_VBYTE_V2 }); |
359 | 511 |
|
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; |
364 | 516 |
|
365 | 517 | let krsFee = BigInt(0); |
| 518 | + let krsFeeAddress: string | undefined; |
| 519 | + |
366 | 520 | if (isKrsRecovery && params.krsProvider) { |
367 | 521 | try { |
368 | 522 | krsFee = BigInt(await calculateFeeAmount(coin, { provider: params.krsProvider })); |
369 | 523 | } catch (err) { |
370 | 524 | // Don't let this error block the recovery - |
371 | 525 | console.dir(err); |
372 | 526 | } |
373 | | - } |
374 | 527 |
|
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 | + } |
380 | 533 |
|
381 | | - krsFeeAddress = krsProvider.feeAddresses[coin.getChain()]; |
| 534 | + krsFeeAddress = krsProviderConfig.feeAddresses[coin.getChain()]; |
382 | 535 |
|
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 | + } |
385 | 539 | } |
386 | 540 | } |
387 | 541 |
|
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, |
390 | 560 | recoveryDestination: params.recoveryDestination, |
391 | | - keyRecoveryServiceFee: krsFee, |
392 | | - keyRecoveryServiceFeeAddress: krsFeeAddress, |
| 561 | + krsProvider: params.krsProvider, |
| 562 | + backupKey: params.backupKey, |
| 563 | + unspents, |
393 | 564 | }); |
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; |
436 | 565 | } |
437 | 566 |
|
438 | 567 | export interface BitGoV1Unspent { |
|
0 commit comments