|
| 1 | +import * as utxolib from '@bitgo/utxo-lib'; |
| 2 | +import { Psbt } from '@bitgo/utxo-lib'; |
| 3 | +import { WatchOnlyAccount, WithdrawBaseOutputUTXO } from '../codecs'; |
| 4 | +import { LightningOnchainRecipient } from '@bitgo/public-types'; |
| 5 | +import { Bip32Derivation } from 'bip174/src/lib/interfaces'; |
| 6 | + |
| 7 | +function parseDerivationPath(derivationPath: string): { |
| 8 | + purpose: number; |
| 9 | + change: number; |
| 10 | + addressIndex: number; |
| 11 | +} { |
| 12 | + const pathSegments = derivationPath.split('/'); |
| 13 | + const purpose = Number(pathSegments[1].replace(/'/g, '')); |
| 14 | + const change = Number(pathSegments[pathSegments.length - 2]); |
| 15 | + const addressIndex = Number(pathSegments[pathSegments.length - 1]); |
| 16 | + return { purpose, change, addressIndex }; |
| 17 | +} |
| 18 | + |
| 19 | +function parsePsbtOutputs(psbt: Psbt, network: utxolib.Network): WithdrawBaseOutputUTXO<bigint>[] { |
| 20 | + const parsedOutputs: WithdrawBaseOutputUTXO<bigint>[] = []; |
| 21 | + let bip32Derivation: Bip32Derivation | undefined; |
| 22 | + |
| 23 | + for (let i = 0; i < psbt.data.outputs.length; i++) { |
| 24 | + const output = psbt.data.outputs[i]; |
| 25 | + const txOutput = psbt.txOutputs[i]; |
| 26 | + |
| 27 | + let address = ''; |
| 28 | + const value = txOutput.value; |
| 29 | + let isChange = false; |
| 30 | + |
| 31 | + if (output.bip32Derivation && output.bip32Derivation.length > 0) { |
| 32 | + isChange = true; |
| 33 | + bip32Derivation = output.bip32Derivation[0]; |
| 34 | + } |
| 35 | + if (txOutput.script) { |
| 36 | + address = utxolib.address.fromOutputScript(txOutput.script, network); |
| 37 | + } |
| 38 | + const valueBigInt = BigInt(value); |
| 39 | + |
| 40 | + parsedOutputs.push({ |
| 41 | + address, |
| 42 | + value: valueBigInt, |
| 43 | + change: isChange, |
| 44 | + bip32Derivation, |
| 45 | + }); |
| 46 | + } |
| 47 | + |
| 48 | + return parsedOutputs; |
| 49 | +} |
| 50 | + |
| 51 | +function verifyChangeAddress( |
| 52 | + output: WithdrawBaseOutputUTXO<bigint>, |
| 53 | + accounts: WatchOnlyAccount[], |
| 54 | + network: utxolib.Network |
| 55 | +): void { |
| 56 | + if (!output.bip32Derivation || !output.bip32Derivation.path) { |
| 57 | + throw new Error(`bip32Derivation path not found for change address`); |
| 58 | + } |
| 59 | + // derivation path example: m/84'/0'/0'/1/0 |
| 60 | + const { purpose, change, addressIndex } = parseDerivationPath(output.bip32Derivation.path); |
| 61 | + |
| 62 | + // Find the corresponding account using the purpose |
| 63 | + const account = accounts.find((acc) => acc.purpose === purpose); |
| 64 | + if (!account) { |
| 65 | + throw new Error(`Account not found for purpose: ${purpose}`); |
| 66 | + } |
| 67 | + |
| 68 | + // Create a BIP32 node from the xpub |
| 69 | + const xpubNode = utxolib.bip32.fromBase58(account.xpub, network); |
| 70 | + |
| 71 | + // Derive the public key from the xpub using the change and address index |
| 72 | + const derivedPubkey = xpubNode.derive(change).derive(addressIndex).publicKey; |
| 73 | + |
| 74 | + if (derivedPubkey.toString('hex') !== output.bip32Derivation.pubkey.toString('hex')) { |
| 75 | + throw new Error( |
| 76 | + `Derived pubkey does not match for address: ${output.address}, derived: ${derivedPubkey.toString( |
| 77 | + 'hex' |
| 78 | + )}, expected: ${output.bip32Derivation.pubkey.toString('hex')}` |
| 79 | + ); |
| 80 | + } |
| 81 | + |
| 82 | + // Determine the correct payment type based on the purpose |
| 83 | + let derivedAddress: string | undefined; |
| 84 | + switch (purpose) { |
| 85 | + case 49: // P2SH-P2WPKH (Nested SegWit) |
| 86 | + derivedAddress = utxolib.payments.p2sh({ |
| 87 | + redeem: utxolib.payments.p2wpkh({ |
| 88 | + pubkey: derivedPubkey, |
| 89 | + network, |
| 90 | + }), |
| 91 | + network, |
| 92 | + }).address; |
| 93 | + break; |
| 94 | + case 84: // P2WPKH (Native SegWit) |
| 95 | + derivedAddress = utxolib.payments.p2wpkh({ |
| 96 | + pubkey: derivedPubkey, |
| 97 | + network, |
| 98 | + }).address; |
| 99 | + break; |
| 100 | + case 86: // P2TR (Taproot) |
| 101 | + derivedAddress = utxolib.payments.p2tr({ |
| 102 | + pubkey: derivedPubkey, |
| 103 | + network, |
| 104 | + }).address; |
| 105 | + break; |
| 106 | + default: |
| 107 | + throw new Error(`Unsupported purpose: ${purpose}`); |
| 108 | + } |
| 109 | + |
| 110 | + if (derivedAddress !== output.address) { |
| 111 | + throw new Error(`invalid change address: expected ${derivedAddress}, got ${output.address}`); |
| 112 | + } |
| 113 | +} |
| 114 | + |
| 115 | +/** |
| 116 | + * Validates the funded psbt before creating the signatures for withdraw. |
| 117 | + */ |
| 118 | +export function validatePsbtForWithdraw( |
| 119 | + psbtHex: string, |
| 120 | + network: utxolib.Network, |
| 121 | + recipients: LightningOnchainRecipient[], |
| 122 | + accounts: WatchOnlyAccount[] |
| 123 | +): void { |
| 124 | + const parsedPsbt = Psbt.fromHex(psbtHex, { network: network }); |
| 125 | + const outputs = parsePsbtOutputs(parsedPsbt, network); |
| 126 | + outputs.forEach((output) => { |
| 127 | + if (output.change) { |
| 128 | + try { |
| 129 | + verifyChangeAddress(output, accounts, network); |
| 130 | + } catch (e: any) { |
| 131 | + throw new Error(`Unable to verify change address: ${e}`); |
| 132 | + } |
| 133 | + } else { |
| 134 | + let match = false; |
| 135 | + recipients.forEach((recipient) => { |
| 136 | + if (recipient.address === output.address && BigInt(recipient.amountSat) === output.value) { |
| 137 | + match = true; |
| 138 | + } |
| 139 | + }); |
| 140 | + if (!match) { |
| 141 | + throw new Error(`PSBT output ${output.address} with value ${output.value} does not match any recipient`); |
| 142 | + } |
| 143 | + } |
| 144 | + }); |
| 145 | +} |
0 commit comments