diff --git a/src/psbt.js b/src/psbt.js index dab6dbc..f183bbc 100644 --- a/src/psbt.js +++ b/src/psbt.js @@ -278,6 +278,86 @@ class Psbt { } return results.every(res => res === true); } + signHD(hdKeyPair, sighashTypes = [transaction_1.Transaction.SIGHASH_ALL]) { + if (!hdKeyPair || !hdKeyPair.publicKey || !hdKeyPair.fingerprint) { + throw new Error('Need HDSigner to sign input'); + } + const results = []; + for (const i of range(this.data.inputs.length)) { + try { + this.signInputHD(i, hdKeyPair, sighashTypes); + results.push(true); + } catch (err) { + results.push(false); + } + } + if (results.every(v => v === false)) { + throw new Error('No inputs were signed'); + } + return this; + } + signHDAsync( + hdKeyPair, + sighashTypes = [transaction_1.Transaction.SIGHASH_ALL], + ) { + return new Promise((resolve, reject) => { + if (!hdKeyPair || !hdKeyPair.publicKey || !hdKeyPair.fingerprint) { + return reject(new Error('Need HDSigner to sign input')); + } + const results = []; + const promises = []; + for (const i of range(this.data.inputs.length)) { + promises.push( + this.signInputHDAsync(i, hdKeyPair, sighashTypes).then( + () => { + results.push(true); + }, + () => { + results.push(false); + }, + ), + ); + } + return Promise.all(promises).then(() => { + if (results.every(v => v === false)) { + return reject(new Error('No inputs were signed')); + } + resolve(); + }); + }); + } + signInputHD( + inputIndex, + hdKeyPair, + sighashTypes = [transaction_1.Transaction.SIGHASH_ALL], + ) { + if (!hdKeyPair || !hdKeyPair.publicKey || !hdKeyPair.fingerprint) { + throw new Error('Need HDSigner to sign input'); + } + const signers = getSignersFromHD(inputIndex, this.data.inputs, hdKeyPair); + signers.forEach(signer => this.signInput(inputIndex, signer, sighashTypes)); + return this; + } + signInputHDAsync( + inputIndex, + hdKeyPair, + sighashTypes = [transaction_1.Transaction.SIGHASH_ALL], + ) { + return new Promise((resolve, reject) => { + if (!hdKeyPair || !hdKeyPair.publicKey || !hdKeyPair.fingerprint) { + return reject(new Error('Need HDSigner to sign input')); + } + const signers = getSignersFromHD(inputIndex, this.data.inputs, hdKeyPair); + const promises = signers.map(signer => + this.signInputAsync(inputIndex, signer, sighashTypes), + ); + return Promise.all(promises) + .then(() => { + resolve(); + }) + .catch(reject); + }); + } sign(keyPair, sighashTypes = [transaction_1.Transaction.SIGHASH_ALL]) { if (!keyPair || !keyPair.publicKey) throw new Error('Need Signer to sign input'); @@ -853,6 +933,34 @@ function getScriptFromInput(inputIndex, input, cache) { } return res; } +function getSignersFromHD(inputIndex, inputs, hdKeyPair) { + const input = utils_1.checkForInput(inputs, inputIndex); + if (!input.bip32Derivation || input.bip32Derivation.length === 0) { + throw new Error('Need bip32Derivation to sign with HD'); + } + const myDerivations = input.bip32Derivation + .map(bipDv => { + if (bipDv.masterFingerprint.equals(hdKeyPair.fingerprint)) { + return bipDv; + } else { + return; + } + }) + .filter(v => !!v); + if (myDerivations.length === 0) { + throw new Error( + 'Need one bip32Derivation masterFingerprint to match the HDSigner fingerprint', + ); + } + const signers = myDerivations.map(bipDv => { + const node = hdKeyPair.derivePath(bipDv.path); + if (!bipDv.pubkey.equals(node.publicKey)) { + throw new Error('pubkey did not match bip32Derivation'); + } + return node; + }); + return signers; +} function getSortedSigs(script, partialSig) { const p2ms = payments.p2ms({ output: script }); // for each pubkey in order of p2ms script diff --git a/test/fixtures/psbt.json b/test/fixtures/psbt.json index 8d83e9d..528e922 100644 --- a/test/fixtures/psbt.json +++ b/test/fixtures/psbt.json @@ -448,6 +448,52 @@ } ] }, + "signInputHD": { + "checks": [ + { + "description": "checks the bip32Derivation exists", + "shouldSign": { + "psbt": "cHNidP8BADMBAAAAARtEptsZNydT9Bh9A5ptwIZz87yH8NXwzr1bjJorAZEAAAAAAAD/////AAAAAAAAAQDAAgAAAAH//////////////////////////////////////////wAAAABrSDBFAiEAjtCPUj0vx3I5HFQKAUWHN0vCnT17jd41/omb4nobq/sCIAilSeQVi4mqykgBbs+Wz6PyqdMThi2gT463v4kPWk6cASECaSihTgej6zyYUQLWkPnBx68mOUGCIuXcWbZDMArbhWH/////AQDh9QUAAAAAGXapFJWXNY2Vp7E5pNOey64rnhhgUlohiKwAAAAAIgYDn85vSg5rlR25fd4MOU2ANsFoO+q828zuOI/5b8tj89kYBCppsiwAAIAAAACAAAAAgAAAAAAAAAAAAAA=", + "inputToCheck": 0, + "xprv": "xprv9s21ZrQH143K2XNCa3o3tii6nbyJAET6GjTfzcF6roTjAMzLUBe8nt7QHNYqKah8JBv8V67MTWBCqPptRr6khjTSvCUVru78KHW13Viwnev" + }, + "shouldThrow": { + "errorMessage": "Need bip32Derivation to sign with HD", + "psbt": "cHNidP8BADMBAAAAAXVa+rWvBGNyifYXEMlTten9+qC0xuHcAMxQYrQTwX1dAAAAAAD/////AAAAAAAAAQDAAgAAAAH//////////////////////////////////////////wAAAABrSDBFAiEAjtCPUj0vx3I5HFQKAUWHN0vCnT17jd41/omb4nobq/sCIAilSeQVi4mqykgBbs+Wz6PyqdMThi2gT463v4kPWk6cASECaSihTgej6zyYUQLWkPnBx68mOUGCIuXcWbZDMArbhWH/////AQDh9QUAAAAAGXapFC8spHIOpiw9giaEPd5RGkMYvXRHiKwAAAAAAAA=", + "inputToCheck": 0, + "xprv": "xprv9s21ZrQH143K2XNCa3o3tii6nbyJAET6GjTfzcF6roTjAMzLUBe8nt7QHNYqKah8JBv8V67MTWBCqPptRr6khjTSvCUVru78KHW13Viwnev" + } + }, + { + "description": "checks the bip32Derivation exists", + "shouldSign": { + "psbt": "cHNidP8BADMBAAAAARtEptsZNydT9Bh9A5ptwIZz87yH8NXwzr1bjJorAZEAAAAAAAD/////AAAAAAAAAQDAAgAAAAH//////////////////////////////////////////wAAAABrSDBFAiEAjtCPUj0vx3I5HFQKAUWHN0vCnT17jd41/omb4nobq/sCIAilSeQVi4mqykgBbs+Wz6PyqdMThi2gT463v4kPWk6cASECaSihTgej6zyYUQLWkPnBx68mOUGCIuXcWbZDMArbhWH/////AQDh9QUAAAAAGXapFJWXNY2Vp7E5pNOey64rnhhgUlohiKwAAAAAIgYDn85vSg5rlR25fd4MOU2ANsFoO+q828zuOI/5b8tj89kYBCppsiwAAIAAAACAAAAAgAAAAAAAAAAAAAA=", + "inputToCheck": 0, + "xprv": "xprv9s21ZrQH143K2XNCa3o3tii6nbyJAET6GjTfzcF6roTjAMzLUBe8nt7QHNYqKah8JBv8V67MTWBCqPptRr6khjTSvCUVru78KHW13Viwnev" + }, + "shouldThrow": { + "errorMessage": "Need one bip32Derivation masterFingerprint to match the HDSigner fingerprint", + "psbt": "cHNidP8BADMBAAAAARtEptsZNydT9Bh9A5ptwIZz87yH8NXwzr1bjJorAZEAAAAAAAD/////AAAAAAAAAQDAAgAAAAH//////////////////////////////////////////wAAAABrSDBFAiEAjtCPUj0vx3I5HFQKAUWHN0vCnT17jd41/omb4nobq/sCIAilSeQVi4mqykgBbs+Wz6PyqdMThi2gT463v4kPWk6cASECaSihTgej6zyYUQLWkPnBx68mOUGCIuXcWbZDMArbhWH/////AQDh9QUAAAAAGXapFJWXNY2Vp7E5pNOey64rnhhgUlohiKwAAAAAIgYD/85vSg5rlR25fd4MOU2ANsFoO+q828zuOI/5b8tj89kY/////ywAAIAAAACAAAAAgAAAAAAAAAAAAAA=", + "inputToCheck": 0, + "xprv": "xprv9s21ZrQH143K2XNCa3o3tii6nbyJAET6GjTfzcF6roTjAMzLUBe8nt7QHNYqKah8JBv8V67MTWBCqPptRr6khjTSvCUVru78KHW13Viwnev" + } + }, + { + "description": "checks the bip32Derivation exists", + "shouldSign": { + "psbt": "cHNidP8BADMBAAAAARtEptsZNydT9Bh9A5ptwIZz87yH8NXwzr1bjJorAZEAAAAAAAD/////AAAAAAAAAQDAAgAAAAH//////////////////////////////////////////wAAAABrSDBFAiEAjtCPUj0vx3I5HFQKAUWHN0vCnT17jd41/omb4nobq/sCIAilSeQVi4mqykgBbs+Wz6PyqdMThi2gT463v4kPWk6cASECaSihTgej6zyYUQLWkPnBx68mOUGCIuXcWbZDMArbhWH/////AQDh9QUAAAAAGXapFJWXNY2Vp7E5pNOey64rnhhgUlohiKwAAAAAIgYDn85vSg5rlR25fd4MOU2ANsFoO+q828zuOI/5b8tj89kYBCppsiwAAIAAAACAAAAAgAAAAAAAAAAAAAA=", + "inputToCheck": 0, + "xprv": "xprv9s21ZrQH143K2XNCa3o3tii6nbyJAET6GjTfzcF6roTjAMzLUBe8nt7QHNYqKah8JBv8V67MTWBCqPptRr6khjTSvCUVru78KHW13Viwnev" + }, + "shouldThrow": { + "errorMessage": "pubkey did not match bip32Derivation", + "psbt": "cHNidP8BADMBAAAAARtEptsZNydT9Bh9A5ptwIZz87yH8NXwzr1bjJorAZEAAAAAAAD/////AAAAAAAAAQDAAgAAAAH//////////////////////////////////////////wAAAABrSDBFAiEAjtCPUj0vx3I5HFQKAUWHN0vCnT17jd41/omb4nobq/sCIAilSeQVi4mqykgBbs+Wz6PyqdMThi2gT463v4kPWk6cASECaSihTgej6zyYUQLWkPnBx68mOUGCIuXcWbZDMArbhWH/////AQDh9QUAAAAAGXapFJWXNY2Vp7E5pNOey64rnhhgUlohiKwAAAAAIgYD/85vSg5rlR25fd4MOU2ANsFoO+q828zuOI/5b8tj89kYBCppsiwAAIAAAACAAAAAgAAAAAAAAAAAAAA=", + "inputToCheck": 0, + "xprv": "xprv9s21ZrQH143K2XNCa3o3tii6nbyJAET6GjTfzcF6roTjAMzLUBe8nt7QHNYqKah8JBv8V67MTWBCqPptRr6khjTSvCUVru78KHW13Viwnev" + } + } + ] + }, "finalizeAllInputs": [ { "type": "P2PK", diff --git a/test/psbt.js b/test/psbt.js index 1837760..18a4c99 100644 --- a/test/psbt.js +++ b/test/psbt.js @@ -1,6 +1,7 @@ const { describe, it } = require('mocha') const assert = require('assert') +const bip32 = require('bip32') const ECPair = require('../src/ecpair') const Psbt = require('..').Psbt const NETWORKS = require('../src/networks') @@ -278,6 +279,130 @@ describe(`Psbt`, () => { }) }) + describe('signInputHDAsync', () => { + fixtures.signInputHD.checks.forEach(f => { + it(f.description, async () => { + if (f.shouldSign) { + const psbtThatShouldsign = Psbt.fromBase64(f.shouldSign.psbt) + assert.doesNotReject(async () => { + await psbtThatShouldsign.signInputHDAsync( + f.shouldSign.inputToCheck, + bip32.fromBase58(f.shouldSign.xprv), + f.shouldSign.sighashTypes || undefined, + ) + }) + } + + if (f.shouldThrow) { + const psbtThatShouldThrow = Psbt.fromBase64(f.shouldThrow.psbt) + assert.rejects(async () => { + await psbtThatShouldThrow.signInputHDAsync( + f.shouldThrow.inputToCheck, + bip32.fromBase58(f.shouldThrow.xprv), + f.shouldThrow.sighashTypes || undefined, + ) + }, new RegExp(f.shouldThrow.errorMessage)) + assert.rejects(async () => { + await psbtThatShouldThrow.signInputHDAsync( + f.shouldThrow.inputToCheck, + ) + }, new RegExp('Need HDSigner to sign input')) + } + }) + }) + }) + + describe('signInputHD', () => { + fixtures.signInputHD.checks.forEach(f => { + it(f.description, () => { + if (f.shouldSign) { + const psbtThatShouldsign = Psbt.fromBase64(f.shouldSign.psbt) + assert.doesNotThrow(() => { + psbtThatShouldsign.signInputHD( + f.shouldSign.inputToCheck, + bip32.fromBase58(f.shouldSign.xprv), + f.shouldSign.sighashTypes || undefined, + ) + }) + } + + if (f.shouldThrow) { + const psbtThatShouldThrow = Psbt.fromBase64(f.shouldThrow.psbt) + assert.throws(() => { + psbtThatShouldThrow.signInputHD( + f.shouldThrow.inputToCheck, + bip32.fromBase58(f.shouldThrow.xprv), + f.shouldThrow.sighashTypes || undefined, + ) + }, new RegExp(f.shouldThrow.errorMessage)) + assert.throws(() => { + psbtThatShouldThrow.signInputHD( + f.shouldThrow.inputToCheck, + ) + }, new RegExp('Need HDSigner to sign input')) + } + }) + }) + }) + + describe('signHDAsync', () => { + fixtures.signInputHD.checks.forEach(f => { + it(f.description, async () => { + if (f.shouldSign) { + const psbtThatShouldsign = Psbt.fromBase64(f.shouldSign.psbt) + assert.doesNotReject(async () => { + await psbtThatShouldsign.signHDAsync( + bip32.fromBase58(f.shouldSign.xprv), + f.shouldSign.sighashTypes || undefined, + ) + }) + } + + if (f.shouldThrow) { + const psbtThatShouldThrow = Psbt.fromBase64(f.shouldThrow.psbt) + assert.rejects(async () => { + await psbtThatShouldThrow.signHDAsync( + bip32.fromBase58(f.shouldThrow.xprv), + f.shouldThrow.sighashTypes || undefined, + ) + }, new RegExp('No inputs were signed')) + assert.rejects(async () => { + await psbtThatShouldThrow.signHDAsync() + }, new RegExp('Need HDSigner to sign input')) + } + }) + }) + }) + + describe('signHD', () => { + fixtures.signInputHD.checks.forEach(f => { + it(f.description, () => { + if (f.shouldSign) { + const psbtThatShouldsign = Psbt.fromBase64(f.shouldSign.psbt) + assert.doesNotThrow(() => { + psbtThatShouldsign.signHD( + bip32.fromBase58(f.shouldSign.xprv), + f.shouldSign.sighashTypes || undefined, + ) + }) + } + + if (f.shouldThrow) { + const psbtThatShouldThrow = Psbt.fromBase64(f.shouldThrow.psbt) + assert.throws(() => { + psbtThatShouldThrow.signHD( + bip32.fromBase58(f.shouldThrow.xprv), + f.shouldThrow.sighashTypes || undefined, + ) + }, new RegExp('No inputs were signed')) + assert.throws(() => { + psbtThatShouldThrow.signHD() + }, new RegExp('Need HDSigner to sign input')) + } + }) + }) + }) + describe('finalizeAllInputs', () => { fixtures.finalizeAllInputs.forEach(f => { it(`Finalizes inputs of type "${f.type}"`, () => { diff --git a/ts_src/psbt.ts b/ts_src/psbt.ts index 14dca4b..0778226 100644 --- a/ts_src/psbt.ts +++ b/ts_src/psbt.ts @@ -332,6 +332,107 @@ export class Psbt { return results.every(res => res === true); } + signHD( + hdKeyPair: HDSigner, + sighashTypes: number[] = [Transaction.SIGHASH_ALL], + ): this { + if (!hdKeyPair || !hdKeyPair.publicKey || !hdKeyPair.fingerprint) { + throw new Error('Need HDSigner to sign input'); + } + + const results: boolean[] = []; + for (const i of range(this.data.inputs.length)) { + try { + this.signInputHD(i, hdKeyPair, sighashTypes); + results.push(true); + } catch (err) { + results.push(false); + } + } + if (results.every(v => v === false)) { + throw new Error('No inputs were signed'); + } + return this; + } + + signHDAsync( + hdKeyPair: HDSigner | HDSignerAsync, + sighashTypes: number[] = [Transaction.SIGHASH_ALL], + ): Promise { + return new Promise( + (resolve, reject): any => { + if (!hdKeyPair || !hdKeyPair.publicKey || !hdKeyPair.fingerprint) { + return reject(new Error('Need HDSigner to sign input')); + } + + const results: boolean[] = []; + const promises: Array> = []; + for (const i of range(this.data.inputs.length)) { + promises.push( + this.signInputHDAsync(i, hdKeyPair, sighashTypes).then( + () => { + results.push(true); + }, + () => { + results.push(false); + }, + ), + ); + } + return Promise.all(promises).then(() => { + if (results.every(v => v === false)) { + return reject(new Error('No inputs were signed')); + } + resolve(); + }); + }, + ); + } + + signInputHD( + inputIndex: number, + hdKeyPair: HDSigner, + sighashTypes: number[] = [Transaction.SIGHASH_ALL], + ): this { + if (!hdKeyPair || !hdKeyPair.publicKey || !hdKeyPair.fingerprint) { + throw new Error('Need HDSigner to sign input'); + } + const signers = getSignersFromHD( + inputIndex, + this.data.inputs, + hdKeyPair, + ) as Signer[]; + signers.forEach(signer => this.signInput(inputIndex, signer, sighashTypes)); + return this; + } + + signInputHDAsync( + inputIndex: number, + hdKeyPair: HDSigner | HDSignerAsync, + sighashTypes: number[] = [Transaction.SIGHASH_ALL], + ): Promise { + return new Promise( + (resolve, reject): any => { + if (!hdKeyPair || !hdKeyPair.publicKey || !hdKeyPair.fingerprint) { + return reject(new Error('Need HDSigner to sign input')); + } + const signers = getSignersFromHD( + inputIndex, + this.data.inputs, + hdKeyPair, + ); + const promises = signers.map(signer => + this.signInputAsync(inputIndex, signer, sighashTypes), + ); + return Promise.all(promises) + .then(() => { + resolve(); + }) + .catch(reject); + }, + ); + } + sign( keyPair: Signer, sighashTypes: number[] = [Transaction.SIGHASH_ALL], @@ -525,6 +626,38 @@ interface PsbtOpts { maximumFeeRate: number; } +interface HDSignerBase { + /** + * DER format compressed publicKey buffer + */ + publicKey: Buffer; + /** + * The first 4 bytes of the sha256-ripemd160 of the publicKey + */ + fingerprint: Buffer; +} + +interface HDSigner extends HDSignerBase { + /** + * The path string must match /^m(\/\d+'?)+$/ + * ex. m/44'/0'/0'/1/23 levels with ' must be hard derivations + */ + derivePath(path: string): HDSigner; + /** + * Input hash (the "message digest") for the signature algorithm + * Return a 64 byte signature (32 byte r and 32 byte s in that order) + */ + sign(hash: Buffer): Buffer; +} + +/** + * Same as above but with async sign method + */ +interface HDSignerAsync extends HDSignerBase { + derivePath(path: string): HDSignerAsync; + sign(hash: Buffer): Promise; +} + /** * This function is needed to pass to the bip174 base class's fromBuffer. * It takes the "transaction buffer" portion of the psbt buffer and returns a @@ -1042,6 +1175,39 @@ function getScriptFromInput( return res; } +function getSignersFromHD( + inputIndex: number, + inputs: PsbtInput[], + hdKeyPair: HDSigner | HDSignerAsync, +): Array { + const input = checkForInput(inputs, inputIndex); + if (!input.bip32Derivation || input.bip32Derivation.length === 0) { + throw new Error('Need bip32Derivation to sign with HD'); + } + const myDerivations = input.bip32Derivation + .map(bipDv => { + if (bipDv.masterFingerprint.equals(hdKeyPair.fingerprint)) { + return bipDv; + } else { + return; + } + }) + .filter(v => !!v); + if (myDerivations.length === 0) { + throw new Error( + 'Need one bip32Derivation masterFingerprint to match the HDSigner fingerprint', + ); + } + const signers: Array = myDerivations.map(bipDv => { + const node = hdKeyPair.derivePath(bipDv!.path); + if (!bipDv!.pubkey.equals(node.publicKey)) { + throw new Error('pubkey did not match bip32Derivation'); + } + return node; + }); + return signers; +} + function getSortedSigs(script: Buffer, partialSig: PartialSig[]): Buffer[] { const p2ms = payments.p2ms({ output: script }); // for each pubkey in order of p2ms script diff --git a/types/psbt.d.ts b/types/psbt.d.ts index 2b24e65..9557f79 100644 --- a/types/psbt.d.ts +++ b/types/psbt.d.ts @@ -61,6 +61,10 @@ export declare class Psbt { finalizeInput(inputIndex: number): this; validateAllSignatures(): boolean; validateSignatures(inputIndex: number, pubkey?: Buffer): boolean; + signHD(hdKeyPair: HDSigner, sighashTypes?: number[]): this; + signHDAsync(hdKeyPair: HDSigner | HDSignerAsync, sighashTypes?: number[]): Promise; + signInputHD(inputIndex: number, hdKeyPair: HDSigner, sighashTypes?: number[]): this; + signInputHDAsync(inputIndex: number, hdKeyPair: HDSigner | HDSignerAsync, sighashTypes?: number[]): Promise; sign(keyPair: Signer, sighashTypes?: number[]): this; signAsync(keyPair: Signer | SignerAsync, sighashTypes?: number[]): Promise; signInput(inputIndex: number, keyPair: Signer, sighashTypes?: number[]): this; @@ -80,4 +84,33 @@ interface PsbtOptsOptional { network?: Network; maximumFeeRate?: number; } +interface HDSignerBase { + /** + * DER format compressed publicKey buffer + */ + publicKey: Buffer; + /** + * The first 4 bytes of the sha256-ripemd160 of the publicKey + */ + fingerprint: Buffer; +} +interface HDSigner extends HDSignerBase { + /** + * The path string must match /^m(\/\d+'?)+$/ + * ex. m/44'/0'/0'/1/23 levels with ' must be hard derivations + */ + derivePath(path: string): HDSigner; + /** + * Input hash (the "message digest") for the signature algorithm + * Return a 64 byte signature (32 byte r and 32 byte s in that order) + */ + sign(hash: Buffer): Buffer; +} +/** + * Same as above but with async sign method + */ +interface HDSignerAsync extends HDSignerBase { + derivePath(path: string): HDSignerAsync; + sign(hash: Buffer): Promise; +} export {};