diff --git a/modules/abstract-lightning/src/lightning/parseWithdrawPsbt.ts b/modules/abstract-lightning/src/lightning/parseWithdrawPsbt.ts index 95314839ec..8ead88b468 100644 --- a/modules/abstract-lightning/src/lightning/parseWithdrawPsbt.ts +++ b/modules/abstract-lightning/src/lightning/parseWithdrawPsbt.ts @@ -110,10 +110,15 @@ export function verifyChangeAddress( }).address; break; case 86: // P2TR (Taproot) - derivedAddress = utxolib.payments.p2tr({ - pubkey: derivedPubkey, - network, - }).address; + // P2TR requires x-only pubkey (32 bytes) + const xOnlyPubkey = derivedPubkey.length === 33 ? derivedPubkey.subarray(1, 33) : derivedPubkey; + derivedAddress = utxolib.payments.p2tr( + { + pubkey: xOnlyPubkey, + network, + }, + { eccLib: utxolib.ecc } + ).address; break; default: throw new Error(`Unsupported purpose: ${purpose}`); diff --git a/modules/abstract-lightning/test/unit/lightning/createPsbt.ts b/modules/abstract-lightning/test/unit/lightning/createPsbt.ts new file mode 100644 index 0000000000..a33ec5db33 --- /dev/null +++ b/modules/abstract-lightning/test/unit/lightning/createPsbt.ts @@ -0,0 +1,175 @@ +import * as utxolib from '@bitgo/utxo-lib'; + +export interface PsbtCreationOptions { + network: utxolib.Network; + inputValue?: number; + outputValue?: number; + outputAddress?: string; + changeValue?: number; + changeDerivationPath?: string; + changePurpose?: 49 | 84 | 86; + includeChangeOutput?: boolean; + masterKey?: utxolib.BIP32Interface; // Optional master key for deriving addresses +} + +export interface PsbtCreationResult { + psbt: utxolib.Psbt; + masterKey: utxolib.BIP32Interface; + changeDerivationPath: string; + changePurpose: number; +} + +/** + * Creates a PSBT for testing purposes with customizable options. + * This helper function generates a PSBT with a fake input and configurable outputs. + * + * @param options - Configuration options for PSBT creation + * @returns A constructed PSBT instance and the master key used + */ +export function createTestPsbt(options: PsbtCreationOptions): PsbtCreationResult { + const { + network, + inputValue = 500000, + outputValue = 100000, + outputAddress, + changeValue, + changeDerivationPath = "m/84'/0'/0'/1/6", + changePurpose = 84, + includeChangeOutput = true, + masterKey, + } = options; + const fixedSeed = Buffer.from('0101010101010101010101010101010101010101010101010101010101010101', 'hex'); + const accountMasterKey = masterKey || utxolib.bip32.fromSeed(fixedSeed, network); + + const inputPrivateKey = Buffer.from('0202020202020202020202020202020202020202020202020202020202020202', 'hex'); + const inputKeyPair = utxolib.ECPair.fromPrivateKey(inputPrivateKey, { network }); + const p2wpkhInput = utxolib.payments.p2wpkh({ + pubkey: Buffer.from(inputKeyPair.publicKey), + network, + }); + + // Create a new PSBT instance + const psbt = new utxolib.Psbt({ network }); + + // Add a fake input to the PSBT + const fakeTxId = 'ca6852598b48230ac870814b935b0d982d3968eb00a1d97332dceb6cd9b8505e'; + const fakeVout = 1; + + psbt.addInput({ + hash: fakeTxId, + index: fakeVout, + witnessUtxo: { + script: p2wpkhInput.output!, + value: BigInt(inputValue), + }, + bip32Derivation: [ + { + masterFingerprint: Buffer.alloc(4, 0), + path: "m/84'/0'/0'/0/0", + pubkey: Buffer.from(inputKeyPair.publicKey), + }, + ], + }); + + // Add recipient output + let recipientAddress: string; + if (outputAddress) { + recipientAddress = outputAddress; + } else { + const recipientPrivateKey = Buffer.from('0303030303030303030303030303030303030303030303030303030303030303', 'hex'); + const recipientKeyPair = utxolib.ECPair.fromPrivateKey(recipientPrivateKey, { network }); + // P2TR requires x-only pubkey (32 bytes, without the prefix byte) + const xOnlyPubkey = + recipientKeyPair.publicKey.length === 33 + ? recipientKeyPair.publicKey.subarray(1, 33) + : recipientKeyPair.publicKey; + const recipientP2tr = utxolib.payments.p2tr( + { + pubkey: xOnlyPubkey, + network, + }, + { eccLib: utxolib.ecc } + ); + recipientAddress = recipientP2tr.address!; + } + + psbt.addOutput({ + address: recipientAddress, + value: BigInt(outputValue), + }); + + // Add change output if requested + if (includeChangeOutput) { + const calculatedChangeValue = changeValue !== undefined ? changeValue : inputValue - outputValue - 10000; // 10k sats fee + + // Parse the derivation path to get the change and address indices + // Expected format: m/purpose'/coin_type'/account'/change/address_index + const pathSegments = changeDerivationPath.split('/'); + const changeIndex = Number(pathSegments[pathSegments.length - 2]); + const addressIndex = Number(pathSegments[pathSegments.length - 1]); + + // Derive the change key from the master key + const changeNode = accountMasterKey.derive(changeIndex).derive(addressIndex); + const changePubkey = changeNode.publicKey; + + let changeAddress: string; + let changePayment; + + // Create change address based on purpose + switch (changePurpose) { + case 49: // P2SH-P2WPKH + changePayment = utxolib.payments.p2sh({ + redeem: utxolib.payments.p2wpkh({ + pubkey: changePubkey, + network, + }), + network, + }); + changeAddress = changePayment.address!; + break; + case 84: // P2WPKH + changePayment = utxolib.payments.p2wpkh({ + pubkey: changePubkey, + network, + }); + changeAddress = changePayment.address!; + break; + case 86: // P2TR + const xOnlyChangePubkey = changePubkey.length === 33 ? changePubkey.subarray(1, 33) : changePubkey; + changePayment = utxolib.payments.p2tr( + { + pubkey: xOnlyChangePubkey, + network, + }, + { eccLib: utxolib.ecc } + ); + changeAddress = changePayment.address!; + break; + default: + throw new Error(`Unsupported purpose: ${changePurpose}`); + } + + psbt.addOutput({ + address: changeAddress, + value: BigInt(calculatedChangeValue), + }); + + // Add bip32Derivation to the change output + psbt.updateOutput(1, { + bip32Derivation: [ + { + masterFingerprint: Buffer.alloc(4, 0), + path: changeDerivationPath, + pubkey: changePubkey, + }, + ], + }); + } + + return { + psbt, + masterKey: accountMasterKey, + changeDerivationPath, + changePurpose, + }; +} diff --git a/modules/abstract-lightning/test/unit/lightning/parseWithdrawPsbt.ts b/modules/abstract-lightning/test/unit/lightning/parseWithdrawPsbt.ts index 1a85d56948..6a4289b810 100644 --- a/modules/abstract-lightning/test/unit/lightning/parseWithdrawPsbt.ts +++ b/modules/abstract-lightning/test/unit/lightning/parseWithdrawPsbt.ts @@ -1,88 +1,473 @@ import { validatePsbtForWithdraw } from '../../../src'; import * as utxolib from '@bitgo/utxo-lib'; import assert from 'assert'; +import { createTestPsbt } from './createPsbt'; describe('parseWithdrawPsbt', () => { - const unsignedPsbtHex = - '70736274ff01007d02000000015e50b8d96cebdc3273d9a100eb68392d980d5b934b8170c80a23488b595268ca0100000000ffffffff02a086010000000000225120379bc88cc15d3605ed9de1b61ff8d65021b650db1151e751a30885ccfcc7d15affa80a000000000016001480a06f2e6b77e817fd5de6e41ea512c563c26cb800000000000100ea02000000000101a158d806735bb7c54e4c701d4f5821cd5342d48d5e1fcbed1169e6e45aa444be0100000000ffffffff02a086010000000000225120379bc88cc15d3605ed9de1b61ff8d65021b650db1151e751a30885ccfcc7d15a6a310c000000000016001478a5d98c7160484b9b00f1782803c58edfc49b9a024730440220407d9162f52371df246dcfa2943d40fbdcb0d4b6768f7682c65193378b2845a60220101c7bc460c93d2976961ac23400f0f10c145efb989a3addb7f03ebaaa2200950121037e17444c85c8b7da07f12fd53cb2ca142c2b4932d0f898649c4b5be0021da0980000000001030401000000220602e57146e5b4762a7ff374adf4072047b67ef115ad46a34189bdeb6a4f88db9b0818000000005400008000000080000000800100000006000000000022020379abbe44004ff7e527bdee3dd8d95e5cd250053f35ee92258b97aa83dfa93c621800000000540000800000008000000080010000005000000000'; const network = utxolib.networks.testnet; - const recipients = [ - { - amountSat: 100000n, - address: 'tb1px7du3rxpt5mqtmvauxmpl7xk2qsmv5xmz9g7w5drpzzuelx869dqwape7k', - }, - ]; - const accounts = [ - { - xpub: 'tpubDCmiWMkTJrZ24t1Z6ECR3HyynCyZ9zGsWqhcLh6H4yFK2CDozSszD1pP2Li4Nx1YYtRcvmNbdb3nD1SzFejYtPFfTocTv2EaAgJCg4zpJpA', - purpose: 49, - coin_type: 0, - account: 0, - }, - { - xpub: 'tpubDCFN7bsxR9UTKggdH2pmv5HeHGQNiDrJwa1EZFtP9sH5PF28i37FHpoYSYARQkKZ6Mi98pkp7oypDcxFmE4dQGq8jV8Gv3L6gmWBeRwPxkP', - purpose: 84, - coin_type: 0, - account: 0, - }, - ]; - it('should parse a valid withdraw PSBT', () => { - validatePsbtForWithdraw(unsignedPsbtHex, network, recipients, accounts); - }); - it('should throw for invalid PSBT', () => { - assert.throws(() => { - validatePsbtForWithdraw('asdasd', network, recipients, accounts); - }, /ERR_BUFFER_OUT_OF_BOUNDS/); - }); - it('should throw for invalid recipient address', () => { - const differentRecipients = [ - { - ...recipients[0], - address: 'tb1qxyzxyzxyzxyzxyzxyzxyzxyzxyzxyzxyzxyzxyzxyzxyzxyzxyz', - }, - ]; - assert.throws(() => { - validatePsbtForWithdraw(unsignedPsbtHex, network, differentRecipients, accounts); - }, /PSBT output tb1px7du3rxpt5mqtmvauxmpl7xk2qsmv5xmz9g7w5drpzzuelx869dqwape7k with value 100000 does not match any recipient/); - }); - it('should throw for invalid recipient value', () => { - const differentRecipients = [ + + describe('regression tests with hardcoded PSBT', () => { + // Keeping one regression test with real PSBT data to ensure backward compatibility + const unsignedPsbtHex = + '70736274ff01007d02000000015e50b8d96cebdc3273d9a100eb68392d980d5b934b8170c80a23488b595268ca0100000000ffffffff02a086010000000000225120379bc88cc15d3605ed9de1b61ff8d65021b650db1151e751a30885ccfcc7d15affa80a000000000016001480a06f2e6b77e817fd5de6e41ea512c563c26cb800000000000100ea02000000000101a158d806735bb7c54e4c701d4f5821cd5342d48d5e1fcbed1169e6e45aa444be0100000000ffffffff02a086010000000000225120379bc88cc15d3605ed9de1b61ff8d65021b650db1151e751a30885ccfcc7d15a6a310c000000000016001478a5d98c7160484b9b00f1782803c58edfc49b9a024730440220407d9162f52371df246dcfa2943d40fbdcb0d4b6768f7682c65193378b2845a60220101c7bc460c93d2976961ac23400f0f10c145efb989a3addb7f03ebaaa2200950121037e17444c85c8b7da07f12fd53cb2ca142c2b4932d0f898649c4b5be0021da0980000000001030401000000220602e57146e5b4762a7ff374adf4072047b67ef115ad46a34189bdeb6a4f88db9b0818000000005400008000000080000000800100000006000000000022020379abbe44004ff7e527bdee3dd8d95e5cd250053f35ee92258b97aa83dfa93c621800000000540000800000008000000080010000005000000000'; + const recipients = [ { - ...recipients[0], - amountSat: 99999n, + amountSat: 100000n, + address: 'tb1px7du3rxpt5mqtmvauxmpl7xk2qsmv5xmz9g7w5drpzzuelx869dqwape7k', }, ]; - assert.throws(() => { - validatePsbtForWithdraw(unsignedPsbtHex, network, differentRecipients, accounts); - }, /PSBT output tb1px7du3rxpt5mqtmvauxmpl7xk2qsmv5xmz9g7w5drpzzuelx869dqwape7k with value 100000 does not match any recipient/); - }); - it('should throw for account not found', () => { - const incompatibleAccounts = []; - assert.throws(() => { - validatePsbtForWithdraw(unsignedPsbtHex, network, recipients, incompatibleAccounts); - }, /Account not found for purpose/); - }); - it('should throw for invalid pubkey', () => { - const incompatibleAccounts = [ + const accounts = [ { - ...accounts[1], xpub: 'tpubDCmiWMkTJrZ24t1Z6ECR3HyynCyZ9zGsWqhcLh6H4yFK2CDozSszD1pP2Li4Nx1YYtRcvmNbdb3nD1SzFejYtPFfTocTv2EaAgJCg4zpJpA', + purpose: 49, + coin_type: 0, + account: 0, }, - ]; - assert.throws(() => { - validatePsbtForWithdraw(unsignedPsbtHex, network, recipients, incompatibleAccounts); - }, /Derived pubkey does not match for address/); - }); - it('should throw for invalid purpose', () => { - const incompatibleAccounts = [ { - ...accounts[1], - purpose: 1017, + xpub: 'tpubDCFN7bsxR9UTKggdH2pmv5HeHGQNiDrJwa1EZFtP9sH5PF28i37FHpoYSYARQkKZ6Mi98pkp7oypDcxFmE4dQGq8jV8Gv3L6gmWBeRwPxkP', + purpose: 84, + coin_type: 0, + account: 0, }, ]; - const incompatiblePsbt = `70736274ff01007d02000000015e50b8d96cebdc3273d9a100eb68392d980d5b934b8170c80a23488b595268ca0100000000ffffffff02a086010000000000225120379bc88cc15d3605ed9de1b61ff8d65021b650db1151e751a30885ccfcc7d15affa80a000000000016001480a06f2e6b77e817fd5de6e41ea512c563c26cb800000000000100ea02000000000101a158d806735bb7c54e4c701d4f5821cd5342d48d5e1fcbed1169e6e45aa444be0100000000ffffffff02a086010000000000225120379bc88cc15d3605ed9de1b61ff8d65021b650db1151e751a30885ccfcc7d15a6a310c000000000016001478a5d98c7160484b9b00f1782803c58edfc49b9a024730440220407d9162f52371df246dcfa2943d40fbdcb0d4b6768f7682c65193378b2845a60220101c7bc460c93d2976961ac23400f0f10c145efb989a3addb7f03ebaaa2200950121037e17444c85c8b7da07f12fd53cb2ca142c2b4932d0f898649c4b5be0021da0980000000001030401000000220602e57146e5b4762a7ff374adf4072047b67ef115ad46a34189bdeb6a4f88db9b0818000000005400008000000080000000800100000006000000000022020379abbe44004ff7e527bdee3dd8d95e5cd250053f35ee92258b97aa83dfa93c621800000000f90300800000008000000080010000005000000000`; - assert.throws(() => { - validatePsbtForWithdraw(incompatiblePsbt, network, recipients, incompatibleAccounts); - }, /Unsupported purpose/); + + it('should parse a valid withdraw PSBT', () => { + validatePsbtForWithdraw(unsignedPsbtHex, network, recipients, accounts); + }); + + it('should throw for invalid PSBT hex', () => { + assert.throws(() => { + validatePsbtForWithdraw('asdasd', network, recipients, accounts); + }, /ERR_BUFFER_OUT_OF_BOUNDS/); + }); + }); + + describe('test cases with creating psbt on the go', () => { + it('should validate PSBT with P2WPKH (purpose 84) change address', () => { + const { psbt, masterKey } = createTestPsbt({ + network, + inputValue: 500000, + outputValue: 100000, + changeValue: 390000, + changePurpose: 84, + changeDerivationPath: "m/84'/0'/0'/1/6", + }); + + const recipientAddress = utxolib.address.fromOutputScript(psbt.txOutputs[0].script, network); + + const recipients = [ + { + amountSat: BigInt(100000), + address: recipientAddress, + }, + ]; + + const accounts = [ + { + xpub: masterKey.neutered().toBase58(), + purpose: 84, + coin_type: 0, + account: 0, + }, + ]; + + validatePsbtForWithdraw(psbt.toHex(), network, recipients, accounts); + }); + + it('should validate PSBT with P2SH-P2WPKH (purpose 49) change address', () => { + const { psbt, masterKey } = createTestPsbt({ + network, + inputValue: 500000, + outputValue: 100000, + changeValue: 390000, + changePurpose: 49, + changeDerivationPath: "m/49'/0'/0'/1/6", + }); + + const recipientAddress = utxolib.address.fromOutputScript(psbt.txOutputs[0].script, network); + + const recipients = [ + { + amountSat: BigInt(100000), + address: recipientAddress, + }, + ]; + + const accounts = [ + { + xpub: masterKey.neutered().toBase58(), + purpose: 49, + coin_type: 0, + account: 0, + }, + ]; + + validatePsbtForWithdraw(psbt.toHex(), network, recipients, accounts); + }); + + it('should validate PSBT with P2TR (purpose 86) change address', () => { + const { psbt, masterKey } = createTestPsbt({ + network, + inputValue: 500000, + outputValue: 100000, + changeValue: 390000, + changePurpose: 86, + changeDerivationPath: "m/86'/0'/0'/1/6", + }); + + const recipientAddress = utxolib.address.fromOutputScript(psbt.txOutputs[0].script, network); + + const recipients = [ + { + amountSat: BigInt(100000), + address: recipientAddress, + }, + ]; + + const accounts = [ + { + xpub: masterKey.neutered().toBase58(), + purpose: 86, + coin_type: 0, + account: 0, + }, + ]; + + validatePsbtForWithdraw(psbt.toHex(), network, recipients, accounts); + }); + + it('should throw for missing bip32Derivation path on change output', () => { + const { psbt, masterKey } = createTestPsbt({ + network, + inputValue: 500000, + outputValue: 100000, + changeValue: 390000, + changePurpose: 84, + }); + + // Remove the bip32Derivation from the change output + // This will cause the output to be treated as a regular recipient output, not a change output + delete psbt.data.outputs[1].bip32Derivation; + + const recipientAddress = utxolib.address.fromOutputScript(psbt.txOutputs[0].script, network); + + const recipients = [ + { + amountSat: BigInt(100000), + address: recipientAddress, + }, + ]; + + const accounts = [ + { + xpub: masterKey.neutered().toBase58(), + purpose: 84, + coin_type: 0, + account: 0, + }, + ]; + + // The output without bip32Derivation is treated as a recipient output, + // and since it's not in the recipients list, it should fail with "does not match any recipient" + assert.throws(() => { + validatePsbtForWithdraw(psbt.toHex(), network, recipients, accounts); + }, /does not match any recipient/); + }); + + it('should throw for invalid change address (derived address mismatch)', () => { + const { psbt } = createTestPsbt({ + network, + inputValue: 500000, + outputValue: 100000, + changeValue: 390000, + changePurpose: 84, + changeDerivationPath: "m/84'/0'/0'/1/6", + }); + + const recipientAddress = utxolib.address.fromOutputScript(psbt.txOutputs[0].script, network); + + const recipients = [ + { + amountSat: BigInt(100000), + address: recipientAddress, + }, + ]; + + // Use a different xpub that won't match the change address + const differentMasterKey = utxolib.bip32.fromSeed(Buffer.alloc(32, 2), network); + const accounts = [ + { + xpub: differentMasterKey.neutered().toBase58(), + purpose: 84, + coin_type: 0, + account: 0, + }, + ]; + + assert.throws(() => { + validatePsbtForWithdraw(psbt.toHex(), network, recipients, accounts); + }, /Derived pubkey does not match for address/); + }); + + it('should validate PSBT with upub prefix (P2SH-P2WPKH testnet)', () => { + const { psbt } = createTestPsbt({ + network, + inputValue: 500000, + outputValue: 100000, + changeValue: 390000, + changePurpose: 49, + changeDerivationPath: "m/49'/0'/0'/1/6", + }); + + const recipientAddress = utxolib.address.fromOutputScript(psbt.txOutputs[0].script, network); + + const recipients = [ + { + amountSat: BigInt(100000), + address: recipientAddress, + }, + ]; + + // Use upub prefix (testnet P2SH-P2WPKH) instead of standard tpub + // This tests that revertXpubPrefix correctly converts upub -> tpub + const accounts = [ + { + xpub: 'upub5Eep7H5q39PzQZLVEYLBytDyBNeV74E8mQsyeL6UozFq9Y3MsZ52G7YGuqrJPgoyAqF7TBeJdnkrHrVrB5pkWkPJ9cJGAePMU6F1Gyw6aFH', + purpose: 49, + coin_type: 0, + account: 0, + }, + ]; + + // This should work because revertXpubPrefix will convert upub back to tpub + // However, the master key we used is different, so it should fail with pubkey mismatch + assert.throws(() => { + validatePsbtForWithdraw(psbt.toHex(), network, recipients, accounts); + }, /Derived pubkey does not match for address/); + }); + + it('should validate PSBT with vpub prefix (P2WPKH testnet)', () => { + const { psbt } = createTestPsbt({ + network, + inputValue: 500000, + outputValue: 100000, + changeValue: 390000, + changePurpose: 84, + changeDerivationPath: "m/84'/0'/0'/1/6", + }); + + const recipientAddress = utxolib.address.fromOutputScript(psbt.txOutputs[0].script, network); + + const recipients = [ + { + amountSat: BigInt(100000), + address: recipientAddress, + }, + ]; + + // Use vpub prefix (testnet P2WPKH) instead of standard tpub + // This tests that revertXpubPrefix correctly converts vpub -> tpub + const accounts = [ + { + xpub: 'vpub5ZU1PHGpQoDSHckYico4nsvwsD3mTh6UjqL5zyGWXZXzBjTYMNKot7t9eRPQY71hJcnNN9r1ss25g3xA9rmoJ5nWPg8jEWavrttnsVa1qw1', + purpose: 84, + coin_type: 0, + account: 0, + }, + ]; + + // This should work because revertXpubPrefix will convert vpub back to tpub + // However, the master key we used is different, so it should fail with pubkey mismatch + assert.throws(() => { + validatePsbtForWithdraw(psbt.toHex(), network, recipients, accounts); + }, /Derived pubkey does not match for address/); + }); + + it('should validate PSBT with matching upub prefix and correct key', () => { + // Import the signer root key from the fixture which corresponds to the upub/vpub accounts + const signerRootKey = + 'tprv8ZgxMBicQKsPe6jS4vDm2n7s42Q6MpvghUQqMmSKG7bTZvGKtjrcU3PGzMNG37yzxywrcdvgkwrr8eYXJmbwdvUNVT4Ucv7ris4jvA7BUmg'; + const masterHDNode = utxolib.bip32.fromBase58(signerRootKey, network); + + // Derive the account key for purpose 49 + const accountKey = masterHDNode.derivePath("m/49'/0'/0'"); + + const { psbt } = createTestPsbt({ + network, + inputValue: 500000, + outputValue: 100000, + changeValue: 390000, + changePurpose: 49, + changeDerivationPath: "m/49'/0'/0'/1/6", + masterKey: accountKey, + }); + + const recipientAddress = utxolib.address.fromOutputScript(psbt.txOutputs[0].script, network); + + const recipients = [ + { + amountSat: BigInt(100000), + address: recipientAddress, + }, + ]; + + // Use the upub from the fixture - this should work because the keys match + const accounts = [ + { + xpub: 'upub5Eep7H5q39PzQZLVEYLBytDyBNeV74E8mQsyeL6UozFq9Y3MsZ52G7YGuqrJPgoyAqF7TBeJdnkrHrVrB5pkWkPJ9cJGAePMU6F1Gyw6aFH', + purpose: 49, + coin_type: 0, + account: 0, + }, + ]; + + // This should pass - revertXpubPrefix converts upub -> tpub, and the key matches + validatePsbtForWithdraw(psbt.toHex(), network, recipients, accounts); + }); + + it('should validate PSBT with matching vpub prefix and correct key', () => { + // Import the signer root key from the fixture which corresponds to the upub/vpub accounts + const signerRootKey = + 'tprv8ZgxMBicQKsPe6jS4vDm2n7s42Q6MpvghUQqMmSKG7bTZvGKtjrcU3PGzMNG37yzxywrcdvgkwrr8eYXJmbwdvUNVT4Ucv7ris4jvA7BUmg'; + const masterHDNode = utxolib.bip32.fromBase58(signerRootKey, network); + + // Derive the account key for purpose 84 + const accountKey = masterHDNode.derivePath("m/84'/0'/0'"); + + const { psbt } = createTestPsbt({ + network, + inputValue: 500000, + outputValue: 100000, + changeValue: 390000, + changePurpose: 84, + changeDerivationPath: "m/84'/0'/0'/1/6", + masterKey: accountKey, + }); + + const recipientAddress = utxolib.address.fromOutputScript(psbt.txOutputs[0].script, network); + + const recipients = [ + { + amountSat: BigInt(100000), + address: recipientAddress, + }, + ]; + + // Use the vpub from the fixture - this should work because the keys match + const accounts = [ + { + xpub: 'vpub5ZU1PHGpQoDSHckYico4nsvwsD3mTh6UjqL5zyGWXZXzBjTYMNKot7t9eRPQY71hJcnNN9r1ss25g3xA9rmoJ5nWPg8jEWavrttnsVa1qw1', + purpose: 84, + coin_type: 0, + account: 0, + }, + ]; + + // This should pass - revertXpubPrefix converts vpub -> tpub, and the key matches + validatePsbtForWithdraw(psbt.toHex(), network, recipients, accounts); + }); + + it('should validate PSBT with multiple recipients', () => { + // Create a PSBT manually with 2 recipient outputs and 1 change output + const masterKey = utxolib.bip32.fromSeed(Buffer.alloc(32, 1), network); + + const inputKeyPair = utxolib.ECPair.makeRandom({ network }); + const p2wpkhInput = utxolib.payments.p2wpkh({ + pubkey: Buffer.from(inputKeyPair.publicKey), + network, + }); + + const psbt = new utxolib.Psbt({ network }); + + // Add input + psbt.addInput({ + hash: 'ca6852598b48230ac870814b935b0d982d3968eb00a1d97332dceb6cd9b8505e', + index: 1, + witnessUtxo: { + script: p2wpkhInput.output!, + value: BigInt(500000), + }, + bip32Derivation: [ + { + masterFingerprint: Buffer.alloc(4, 0), + path: "m/84'/0'/0'/0/0", + pubkey: Buffer.from(inputKeyPair.publicKey), + }, + ], + }); + + // Create first recipient + const recipient1KeyPair = utxolib.ECPair.makeRandom({ network }); + const recipient1Payment = utxolib.payments.p2wpkh({ + pubkey: Buffer.from(recipient1KeyPair.publicKey), + network, + }); + const recipient1Address = recipient1Payment.address!; + + // Create second recipient + const recipient2KeyPair = utxolib.ECPair.makeRandom({ network }); + const recipient2Payment = utxolib.payments.p2wpkh({ + pubkey: Buffer.from(recipient2KeyPair.publicKey), + network, + }); + const recipient2Address = recipient2Payment.address!; + + // Derive change address from master key + const changeNode = masterKey.derive(1).derive(6); // m/1/6 + const changePayment = utxolib.payments.p2wpkh({ + pubkey: changeNode.publicKey, + network, + }); + const changeAddress = changePayment.address!; + + // Add outputs: recipient1, recipient2, change + psbt.addOutput({ + address: recipient1Address, + value: BigInt(100000), + }); + + psbt.addOutput({ + address: recipient2Address, + value: BigInt(50000), + }); + + psbt.addOutput({ + address: changeAddress, + value: BigInt(340000), // 500000 - 100000 - 50000 - 10000 (fee) + }); + + // Add bip32Derivation to the change output (index 2) + psbt.updateOutput(2, { + bip32Derivation: [ + { + masterFingerprint: Buffer.alloc(4, 0), + path: "m/84'/0'/0'/1/6", + pubkey: changeNode.publicKey, + }, + ], + }); + + const recipients = [ + { + amountSat: BigInt(100000), + address: recipient1Address, + }, + { + amountSat: BigInt(50000), + address: recipient2Address, + }, + ]; + + const accounts = [ + { + xpub: masterKey.neutered().toBase58(), + purpose: 84, + coin_type: 0, + account: 0, + }, + ]; + + validatePsbtForWithdraw(psbt.toHex(), network, recipients, accounts); + }); }); });