diff --git a/README.md b/README.md index 857fef70e..038211c52 100644 --- a/README.md +++ b/README.md @@ -113,6 +113,66 @@ When utilizing Taproot features with bitcoinjs-lib, you may need to include an a Another alternative library for ECC functionality. This requires access to the global `BigInt` primitive. For advantages and detailed comparison of these libraries, visit: [tiny-secp256k1 GitHub page](https://github.com/bitcoinjs/tiny-secp256k1). +#### Signing a tapscript path without the script + +`signInput` derives the BIP341 tapleaf hash from the leaf script on the input. +`generateTaprootScriptHashSignature` takes that hash directly instead, returning +the same signature without the script ever being present. Unlike the `sign*` +methods it stores nothing - it hands the signature back to you: + +```javascript +const { generateTaprootScriptHashSignature } = require('bitcoinjs-lib'); + +const signature = generateTaprootScriptHashSignature(psbt, 0, keyPair, leafScriptHash); +``` + +The whole `psbt` is required rather than just the input being signed: the BIP341 +sighash commits to every prevout, and depending on the sighash type to the +outputs too, so the PSBT must already hold its inputs and outputs. The same thing +is available as `psbt.generateTaprootScriptHashSignature(0, keyPair, leafScriptHash)`. + +`leafScriptHash` is the tapleaf hash - exactly what `tapleafHash({ output: +script, version: leafVersion })` from `bitcoinjs-lib/src/payments/bip341` +returns - not a plain hash of the script bytes. The leaf version is already +committed to inside it, and the control block plays no part in the signature, so +neither is passed here. + +The PSBT is **not** modified. To attach the signature, put it in a +`tapScriptSig` - a standard field carrying only the pubkey, the signature and the +leaf hash, so it needs no script and serializes normally: + +```javascript +psbt.updateInput(0, { + tapScriptSig: [{ pubkey: toXOnly(keyPair.publicKey), signature, leafHash: leafScriptHash }], +}); +``` + +Sighash type comes from the input, defaulting to `SIGHASH_DEFAULT`. Anything else +must be set on the input *and* whitelisted in the call: + +```javascript +psbt.updateInput(0, { sighashType: Transaction.SIGHASH_ALL }); +const signature = generateTaprootScriptHashSignature(psbt, 0, keyPair, leafScriptHash, [ + Transaction.SIGHASH_ALL, +]); +``` + +**WARNING**: this is a blind signature. Nothing can check that your pubkey appears +in the leaf, that the leaf belongs to the taptree being spent, or what the script +authorises - every guard the normal `signInput` path relies on comes from reading +the script. Only use this when the leaf is authenticated by some other means. + +Two consequences follow from the script being absent. `validateSignaturesOfInput` +cannot check the signature, and `finalizeInput` cannot build the witness. Both +work again once the script is supplied: + +```javascript +psbt.updateInput(0, { + tapLeafScript: [{ leafVersion: LEAF_VERSION_TAPSCRIPT, script, controlBlock }], +}); +psbt.finalizeInput(0); +``` + **NOTE**: We use Node Maintenance LTS features, if you need strict ES5, use [`--transform babelify`](https://github.com/babel/babelify) in conjunction with your `browserify` step (using an [`es2015`](https://babeljs.io/docs/plugins/preset-es2015/) preset). **WARNING**: iOS devices have [problems](https://github.com/feross/buffer/issues/136), use at least [buffer@5.0.5](https://github.com/feross/buffer/pull/155) or greater, and enforce the test suites (for `Buffer`, and any other dependency) pass before use. diff --git a/src/cjs/index.cjs b/src/cjs/index.cjs index 8bd22c864..1217ba5ca 100644 --- a/src/cjs/index.cjs +++ b/src/cjs/index.cjs @@ -47,6 +47,7 @@ Object.defineProperty(exports, '__esModule', { value: true }); exports.initEccLib = exports.Transaction = exports.opcodes = + exports.generateTaprootScriptHashSignature = exports.toXOnly = exports.Psbt = exports.Block = @@ -86,6 +87,12 @@ Object.defineProperty(exports, 'toXOnly', { return psbt_js_1.toXOnly; }, }); +Object.defineProperty(exports, 'generateTaprootScriptHashSignature', { + enumerable: true, + get: function () { + return psbt_js_1.generateTaprootScriptHashSignature; + }, +}); /** @hidden */ var ops_js_1 = require('./ops.cjs'); Object.defineProperty(exports, 'opcodes', { diff --git a/src/cjs/index.d.ts b/src/cjs/index.d.ts index f2badff81..7f091e625 100644 --- a/src/cjs/index.d.ts +++ b/src/cjs/index.d.ts @@ -7,7 +7,7 @@ export { address, crypto, networks, payments, script }; export { Block } from './block.js'; /** @hidden */ export { TaggedHashPrefix } from './crypto.js'; -export { Psbt, PsbtTxInput, PsbtTxOutput, Signer, SignerAsync, HDSigner, HDSignerAsync, toXOnly, } from './psbt.js'; +export { Psbt, PsbtTxInput, PsbtTxOutput, Signer, SignerAsync, HDSigner, HDSignerAsync, toXOnly, generateTaprootScriptHashSignature, } from './psbt.js'; /** @hidden */ export { OPS as opcodes } from './ops.js'; export { Transaction } from './transaction.js'; diff --git a/src/cjs/psbt.cjs b/src/cjs/psbt.cjs index 6e3a5a82d..5d73338fe 100644 --- a/src/cjs/psbt.cjs +++ b/src/cjs/psbt.cjs @@ -45,6 +45,7 @@ var __importStar = }; Object.defineProperty(exports, '__esModule', { value: true }); exports.Psbt = exports.toXOnly = void 0; +exports.generateTaprootScriptHashSignature = generateTaprootScriptHashSignature; const bip174_1 = require('bip174'); const varuint = __importStar(require('varuint-bitcoin')); const bip174_2 = require('bip174'); @@ -720,6 +721,84 @@ class Psbt { ); throw new Error(`Input #${inputIndex} is not of type Taproot.`); } + /** + * Generates a taproot script-path signature for a leaf whose script is not + * known, given only that leaf's BIP341 tapleaf hash. The signature is + * returned; nothing is written to this PSBT. + * + * `scriptHash` is the tapleaf hash, i.e. + * `taggedHash('TapLeaf', leafVersion || compactSize(script) || script)` - the + * exact value `tapleafHash({ output: script, version: leafVersion })` returns, + * and the value the BIP341 script-path sighash commits to. It is NOT a plain + * hash of the script bytes. The leaf version is already committed to inside + * it, and the control block plays no part in the signature, so neither is + * needed here. + * + * The signature is returned rather than written to the PSBT. To attach it, + * put it in a `tapScriptSig` - a field that carries only the pubkey, the + * signature and the leaf hash, so no script is needed: + * + * ``` + * psbt.updateInput(inputIndex, { + * tapScriptSig: [{ + * pubkey: toXOnly(keyPair.publicKey), + * signature, + * leafHash: scriptHash, + * }], + * }); + * ``` + * + * WARNING: this is a blind signature. Nothing here can check that your pubkey + * appears in the leaf, that the leaf belongs to the taptree being spent, or + * what the script authorises. Every guard the normal `signInput` path relies + * on comes from reading the script. Only use this when the leaf is + * authenticated by some other means. + * + * @param inputIndex the position of the PSBT input. + * @param keyPair the Schnorr signer. + * @param scriptHash the BIP341 tapleaf hash of the leaf being spent. + * @param sighashTypes whitelist of allowed sighash types. + * @returns the taproot signature, with the sighash byte appended unless the + * sighash type is SIGHASH_DEFAULT. + */ + generateTaprootScriptHashSignature( + inputIndex, + keyPair, + scriptHash, + sighashTypes = [transaction_js_1.Transaction.SIGHASH_DEFAULT], + ) { + if (!keyPair || !keyPair.publicKey) + throw new Error('Need Signer to sign input'); + if (typeof keyPair.signSchnorr !== 'function') + throw new Error( + `Need Schnorr Signer to sign taproot input #${inputIndex}.`, + ); + if (!(scriptHash instanceof Uint8Array) || scriptHash.length !== 32) + throw new Error( + `Need a 32 byte tapleaf hash to sign input #${inputIndex}.`, + ); + const input = (0, bip174_2.checkForInput)(this.data.inputs, inputIndex); + const sighashType = + input.sighashType || transaction_js_1.Transaction.SIGHASH_DEFAULT; + checkSighashTypeAllowed(sighashType, sighashTypes); + // BIP341 commits to every prevout, not just this one. + const prevOuts = this.data.inputs.map((i, index) => + getScriptAndAmountFromUtxo(index, i, this.__CACHE), + ); + if (!(0, psbtutils_js_1.isP2TR)(prevOuts[inputIndex].script)) + throw new Error(`Input #${inputIndex} is not of type Taproot.`); + const hash = this.__CACHE.__TX.hashForWitnessV1( + inputIndex, + prevOuts.map(o => o.script), + prevOuts.map(o => o.value), + sighashType, + scriptHash, + ); + return (0, bip371_js_1.serializeTaprootSignature)( + keyPair.signSchnorr(hash), + input.sighashType, + ); + } _signInput( inputIndex, keyPair, @@ -976,6 +1055,48 @@ class Psbt { } } exports.Psbt = Psbt; +/** + * Generates a taproot script-path signature for a leaf whose script is not + * known, given only that leaf's BIP341 tapleaf hash. The signature is returned; + * the PSBT is not modified. + * + * The whole `psbt` is required, not just the input being signed: the BIP341 + * sighash commits to every prevout, and - depending on the sighash type - to the + * outputs as well. So the PSBT must already hold its inputs and outputs. + * + * `scriptHash` is the tapleaf hash, i.e. + * `taggedHash('TapLeaf', leafVersion || compactSize(script) || script)`. The + * leaf version is already committed to inside it, and the control block plays no + * part in the signature, so neither is passed here. + * + * WARNING: this is a blind signature. Nothing here can check that your pubkey + * appears in the leaf, that the leaf belongs to the taptree being spent, or what + * the script authorises. Every guard the normal signing path relies on comes + * from reading the script. Only use this when the leaf is authenticated by some + * other means. + * + * @param psbt the PSBT, with its inputs and outputs already added. + * @param inputIndex the position of the input to sign. + * @param keyPair the Schnorr signer. + * @param scriptHash the BIP341 tapleaf hash of the leaf being spent. + * @param sighashTypes whitelist of allowed sighash types. + * @returns the taproot signature. The PSBT is not modified; attach it yourself + * with `updateInput(inputIndex, { tapScriptSig: [...] })` if you want it there. + */ +function generateTaprootScriptHashSignature( + psbt, + inputIndex, + keyPair, + scriptHash, + sighashTypes, +) { + return psbt.generateTaprootScriptHashSignature( + inputIndex, + keyPair, + scriptHash, + sighashTypes, + ); +} /** * 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 diff --git a/src/cjs/psbt.d.ts b/src/cjs/psbt.d.ts index 92d65335d..a7547d4d2 100644 --- a/src/cjs/psbt.d.ts +++ b/src/cjs/psbt.d.ts @@ -107,6 +107,47 @@ export declare class Psbt { signAllInputsAsync(keyPair: Signer | SignerAsync, sighashTypes?: number[]): Promise; signInput(inputIndex: number, keyPair: Signer, sighashTypes?: number[]): this; signTaprootInput(inputIndex: number, keyPair: Signer, tapLeafHashToSign?: Uint8Array, sighashTypes?: number[]): this; + /** + * Generates a taproot script-path signature for a leaf whose script is not + * known, given only that leaf's BIP341 tapleaf hash. The signature is + * returned; nothing is written to this PSBT. + * + * `scriptHash` is the tapleaf hash, i.e. + * `taggedHash('TapLeaf', leafVersion || compactSize(script) || script)` - the + * exact value `tapleafHash({ output: script, version: leafVersion })` returns, + * and the value the BIP341 script-path sighash commits to. It is NOT a plain + * hash of the script bytes. The leaf version is already committed to inside + * it, and the control block plays no part in the signature, so neither is + * needed here. + * + * The signature is returned rather than written to the PSBT. To attach it, + * put it in a `tapScriptSig` - a field that carries only the pubkey, the + * signature and the leaf hash, so no script is needed: + * + * ``` + * psbt.updateInput(inputIndex, { + * tapScriptSig: [{ + * pubkey: toXOnly(keyPair.publicKey), + * signature, + * leafHash: scriptHash, + * }], + * }); + * ``` + * + * WARNING: this is a blind signature. Nothing here can check that your pubkey + * appears in the leaf, that the leaf belongs to the taptree being spent, or + * what the script authorises. Every guard the normal `signInput` path relies + * on comes from reading the script. Only use this when the leaf is + * authenticated by some other means. + * + * @param inputIndex the position of the PSBT input. + * @param keyPair the Schnorr signer. + * @param scriptHash the BIP341 tapleaf hash of the leaf being spent. + * @param sighashTypes whitelist of allowed sighash types. + * @returns the taproot signature, with the sighash byte appended unless the + * sighash type is SIGHASH_DEFAULT. + */ + generateTaprootScriptHashSignature(inputIndex: number, keyPair: Signer, scriptHash: Uint8Array, sighashTypes?: number[]): Uint8Array; private _signInput; private _signTaprootInput; signInputAsync(inputIndex: number, keyPair: Signer | SignerAsync, sighashTypes?: number[]): Promise; @@ -125,6 +166,35 @@ export declare class Psbt { addUnknownKeyValToOutput(outputIndex: number, keyVal: KeyValue): this; clearFinalizedInput(inputIndex: number): this; } +/** + * Generates a taproot script-path signature for a leaf whose script is not + * known, given only that leaf's BIP341 tapleaf hash. The signature is returned; + * the PSBT is not modified. + * + * The whole `psbt` is required, not just the input being signed: the BIP341 + * sighash commits to every prevout, and - depending on the sighash type - to the + * outputs as well. So the PSBT must already hold its inputs and outputs. + * + * `scriptHash` is the tapleaf hash, i.e. + * `taggedHash('TapLeaf', leafVersion || compactSize(script) || script)`. The + * leaf version is already committed to inside it, and the control block plays no + * part in the signature, so neither is passed here. + * + * WARNING: this is a blind signature. Nothing here can check that your pubkey + * appears in the leaf, that the leaf belongs to the taptree being spent, or what + * the script authorises. Every guard the normal signing path relies on comes + * from reading the script. Only use this when the leaf is authenticated by some + * other means. + * + * @param psbt the PSBT, with its inputs and outputs already added. + * @param inputIndex the position of the input to sign. + * @param keyPair the Schnorr signer. + * @param scriptHash the BIP341 tapleaf hash of the leaf being spent. + * @param sighashTypes whitelist of allowed sighash types. + * @returns the taproot signature. The PSBT is not modified; attach it yourself + * with `updateInput(inputIndex, { tapScriptSig: [...] })` if you want it there. + */ +export declare function generateTaprootScriptHashSignature(psbt: Psbt, inputIndex: number, keyPair: Signer, scriptHash: Uint8Array, sighashTypes?: number[]): Uint8Array; interface PsbtOptsOptional { network?: Network; maximumFeeRate?: number; diff --git a/src/esm/index.js b/src/esm/index.js index 0b87f3023..c1419aaf3 100644 --- a/src/esm/index.js +++ b/src/esm/index.js @@ -5,7 +5,7 @@ import * as payments from './payments/index.js'; import * as script from './script.js'; export { address, crypto, networks, payments, script }; export { Block } from './block.js'; -export { Psbt, toXOnly } from './psbt.js'; +export { Psbt, toXOnly, generateTaprootScriptHashSignature } from './psbt.js'; /** @hidden */ export { OPS as opcodes } from './ops.js'; export { Transaction } from './transaction.js'; diff --git a/src/esm/psbt.js b/src/esm/psbt.js index 15c5e016d..2ac8a6306 100644 --- a/src/esm/psbt.js +++ b/src/esm/psbt.js @@ -669,6 +669,83 @@ export class Psbt { ); throw new Error(`Input #${inputIndex} is not of type Taproot.`); } + /** + * Generates a taproot script-path signature for a leaf whose script is not + * known, given only that leaf's BIP341 tapleaf hash. The signature is + * returned; nothing is written to this PSBT. + * + * `scriptHash` is the tapleaf hash, i.e. + * `taggedHash('TapLeaf', leafVersion || compactSize(script) || script)` - the + * exact value `tapleafHash({ output: script, version: leafVersion })` returns, + * and the value the BIP341 script-path sighash commits to. It is NOT a plain + * hash of the script bytes. The leaf version is already committed to inside + * it, and the control block plays no part in the signature, so neither is + * needed here. + * + * The signature is returned rather than written to the PSBT. To attach it, + * put it in a `tapScriptSig` - a field that carries only the pubkey, the + * signature and the leaf hash, so no script is needed: + * + * ``` + * psbt.updateInput(inputIndex, { + * tapScriptSig: [{ + * pubkey: toXOnly(keyPair.publicKey), + * signature, + * leafHash: scriptHash, + * }], + * }); + * ``` + * + * WARNING: this is a blind signature. Nothing here can check that your pubkey + * appears in the leaf, that the leaf belongs to the taptree being spent, or + * what the script authorises. Every guard the normal `signInput` path relies + * on comes from reading the script. Only use this when the leaf is + * authenticated by some other means. + * + * @param inputIndex the position of the PSBT input. + * @param keyPair the Schnorr signer. + * @param scriptHash the BIP341 tapleaf hash of the leaf being spent. + * @param sighashTypes whitelist of allowed sighash types. + * @returns the taproot signature, with the sighash byte appended unless the + * sighash type is SIGHASH_DEFAULT. + */ + generateTaprootScriptHashSignature( + inputIndex, + keyPair, + scriptHash, + sighashTypes = [Transaction.SIGHASH_DEFAULT], + ) { + if (!keyPair || !keyPair.publicKey) + throw new Error('Need Signer to sign input'); + if (typeof keyPair.signSchnorr !== 'function') + throw new Error( + `Need Schnorr Signer to sign taproot input #${inputIndex}.`, + ); + if (!(scriptHash instanceof Uint8Array) || scriptHash.length !== 32) + throw new Error( + `Need a 32 byte tapleaf hash to sign input #${inputIndex}.`, + ); + const input = checkForInput(this.data.inputs, inputIndex); + const sighashType = input.sighashType || Transaction.SIGHASH_DEFAULT; + checkSighashTypeAllowed(sighashType, sighashTypes); + // BIP341 commits to every prevout, not just this one. + const prevOuts = this.data.inputs.map((i, index) => + getScriptAndAmountFromUtxo(index, i, this.__CACHE), + ); + if (!isP2TR(prevOuts[inputIndex].script)) + throw new Error(`Input #${inputIndex} is not of type Taproot.`); + const hash = this.__CACHE.__TX.hashForWitnessV1( + inputIndex, + prevOuts.map(o => o.script), + prevOuts.map(o => o.value), + sighashType, + scriptHash, + ); + return serializeTaprootSignature( + keyPair.signSchnorr(hash), + input.sighashType, + ); + } _signInput(inputIndex, keyPair, sighashTypes = [Transaction.SIGHASH_ALL]) { const { hash, sighashType } = getHashAndSighashType( this.data.inputs, @@ -911,6 +988,48 @@ export class Psbt { return this; } } +/** + * Generates a taproot script-path signature for a leaf whose script is not + * known, given only that leaf's BIP341 tapleaf hash. The signature is returned; + * the PSBT is not modified. + * + * The whole `psbt` is required, not just the input being signed: the BIP341 + * sighash commits to every prevout, and - depending on the sighash type - to the + * outputs as well. So the PSBT must already hold its inputs and outputs. + * + * `scriptHash` is the tapleaf hash, i.e. + * `taggedHash('TapLeaf', leafVersion || compactSize(script) || script)`. The + * leaf version is already committed to inside it, and the control block plays no + * part in the signature, so neither is passed here. + * + * WARNING: this is a blind signature. Nothing here can check that your pubkey + * appears in the leaf, that the leaf belongs to the taptree being spent, or what + * the script authorises. Every guard the normal signing path relies on comes + * from reading the script. Only use this when the leaf is authenticated by some + * other means. + * + * @param psbt the PSBT, with its inputs and outputs already added. + * @param inputIndex the position of the input to sign. + * @param keyPair the Schnorr signer. + * @param scriptHash the BIP341 tapleaf hash of the leaf being spent. + * @param sighashTypes whitelist of allowed sighash types. + * @returns the taproot signature. The PSBT is not modified; attach it yourself + * with `updateInput(inputIndex, { tapScriptSig: [...] })` if you want it there. + */ +export function generateTaprootScriptHashSignature( + psbt, + inputIndex, + keyPair, + scriptHash, + sighashTypes, +) { + return psbt.generateTaprootScriptHashSignature( + inputIndex, + keyPair, + scriptHash, + sighashTypes, + ); +} /** * 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 diff --git a/test/tapscripthash.spec.ts b/test/tapscripthash.spec.ts new file mode 100644 index 000000000..fed30872c --- /dev/null +++ b/test/tapscripthash.spec.ts @@ -0,0 +1,471 @@ +import * as assert from 'assert'; +import * as ecc from 'tiny-secp256k1'; +import ECPairFactory from 'ecpair'; +import { beforeEach, describe, it } from 'mocha'; +import { randomBytes } from 'crypto'; +import * as tools from 'uint8array-tools'; + +import { + initEccLib, + networks, + payments, + Psbt, + script, + generateTaprootScriptHashSignature, + Transaction, +} from 'bitcoinjs-lib'; +import { + LEAF_VERSION_TAPSCRIPT, + tapleafHash, +} from 'bitcoinjs-lib/src/payments/bip341'; +import { toXOnly } from 'bitcoinjs-lib/src/psbt/bip371'; +import type { Taptree } from 'bitcoinjs-lib/src/types'; + +const ECPair = ECPairFactory(ecc); +const rng = (size: number) => randomBytes(size); +const network = networks.regtest; +const AMOUNT = 42e4; + +interface Fixture { + internalPubkey: Uint8Array; + leafScript: Uint8Array; + otherLeafScript: Uint8Array; + leafHash: Uint8Array; + controlBlock: Uint8Array; + output: Uint8Array; +} + +function buildFixture(keyPair: { publicKey: Uint8Array }): Fixture { + const internalPubkey = toXOnly(ECPair.makeRandom({ rng }).publicKey); + const leafScript = script.fromASM( + `${tools.toHex(toXOnly(keyPair.publicKey))} OP_CHECKSIG`, + ); + // A sibling leaf, so the control block carries a real merkle path. + const otherLeafScript = script.fromASM( + `${tools.toHex(toXOnly(ECPair.makeRandom({ rng }).publicKey))} OP_CHECKSIG`, + ); + const scriptTree: Taptree = [ + { output: leafScript, version: LEAF_VERSION_TAPSCRIPT }, + { output: otherLeafScript, version: LEAF_VERSION_TAPSCRIPT }, + ]; + + const { output, witness } = payments.p2tr({ + internalPubkey, + scriptTree, + redeem: { output: leafScript, redeemVersion: LEAF_VERSION_TAPSCRIPT }, + network, + }); + + return { + internalPubkey, + leafScript, + otherLeafScript, + leafHash: tapleafHash({ + output: leafScript, + version: LEAF_VERSION_TAPSCRIPT, + }), + controlBlock: witness![witness!.length - 1], + output: output!, + }; +} + +function buildPsbt(fixture: Fixture, inputs = 1): Psbt { + const psbt = new Psbt({ network }); + for (let i = 0; i < inputs; i++) { + psbt.addInput({ + hash: tools.toHex(new Uint8Array(32).fill(7 + i)), + index: 0, + witnessUtxo: { value: BigInt(AMOUNT), script: fixture.output }, + }); + } + psbt.addOutput({ + value: BigInt(AMOUNT * inputs - 1e4), + address: payments.p2tr({ internalPubkey: fixture.internalPubkey, network }) + .address!, + }); + return psbt; +} + +const validator = ( + pubkey: Uint8Array, + msghash: Uint8Array, + signature: Uint8Array, +): boolean => ecc.verifySchnorr(msghash, pubkey, signature); + +/** + * Recomputes the BIP341 script-path sighash straight from the unsigned + * transaction and checks the signature against it, without relying on any of + * the library's own signing plumbing. + */ +function verifyIndependently( + psbt: Psbt, + fixture: Fixture, + leafHash: Uint8Array, + pubkey: Uint8Array, + signature: Uint8Array, + sighashType: number = Transaction.SIGHASH_DEFAULT, +): boolean { + const tx = Transaction.fromBuffer(psbt.data.globalMap.unsignedTx.toBuffer()); + const hash = tx.hashForWitnessV1( + 0, + [fixture.output], + [BigInt(AMOUNT)], + sighashType, + leafHash, + ); + return ecc.verifySchnorr(hash, toXOnly(pubkey), signature.slice(0, 64)); +} + +/** Attaches a returned signature to a PSBT the way a caller would. */ +function attach( + psbt: Psbt, + keyPair: { publicKey: Uint8Array }, + leafHash: Uint8Array, + signature: Uint8Array, +): Psbt { + return psbt.updateInput(0, { + tapScriptSig: [ + { + pubkey: toXOnly(keyPair.publicKey), + signature, + leafHash, + }, + ], + }); +} + +describe('generateTaprootScriptHashSignature', () => { + // Other specs deliberately clear the ECC library, so re-arm it here. + beforeEach(() => initEccLib(ecc)); + + it('produces the same signature as signing with the full leaf script', () => { + const keyPair = ECPair.makeRandom({ rng }); + const fixture = buildFixture(keyPair); + + const withScript = buildPsbt(fixture); + withScript.updateInput(0, { + tapLeafScript: [ + { + leafVersion: LEAF_VERSION_TAPSCRIPT, + script: fixture.leafScript, + controlBlock: fixture.controlBlock, + }, + ], + }); + withScript.signInput(0, keyPair); + + const withHash = buildPsbt(fixture); + const signature = generateTaprootScriptHashSignature( + withHash, + 0, + keyPair, + fixture.leafHash, + ); + + assert.deepStrictEqual( + signature, + withScript.data.inputs[0].tapScriptSig![0].signature, + ); + }); + + it('returns the signature and leaves the psbt untouched', () => { + const keyPair = ECPair.makeRandom({ rng }); + const fixture = buildFixture(keyPair); + + const psbt = buildPsbt(fixture); + const before = psbt.toBase64(); + const signature = generateTaprootScriptHashSignature( + psbt, + 0, + keyPair, + fixture.leafHash, + ); + + assert.ok(signature instanceof Uint8Array); + // SIGHASH_DEFAULT: bare 64 byte schnorr signature, no trailing sighash byte. + assert.strictEqual(signature.length, 64); + assert.strictEqual(psbt.data.inputs[0].tapScriptSig, undefined); + assert.strictEqual(psbt.toBase64(), before); + }); + + it('commits to every prevout, not just the one being signed', () => { + const keyPair = ECPair.makeRandom({ rng }); + const fixture = buildFixture(keyPair); + + const twoIn = generateTaprootScriptHashSignature( + buildPsbt(fixture, 2), + 0, + keyPair, + fixture.leafHash, + ); + const oneIn = generateTaprootScriptHashSignature( + buildPsbt(fixture, 1), + 0, + keyPair, + fixture.leafHash, + ); + + assert.notDeepStrictEqual(twoIn, oneIn); + }); + + it('signs a leaf whose script does not contain the signing pubkey', () => { + const keyPair = ECPair.makeRandom({ rng }); + const fixture = buildFixture(keyPair); + const strangerHash = tapleafHash({ + output: fixture.otherLeafScript, + version: LEAF_VERSION_TAPSCRIPT, + }); + + const psbt = buildPsbt(fixture); + const signature = generateTaprootScriptHashSignature( + psbt, + 0, + keyPair, + strangerHash, + ); + + assert.ok( + verifyIndependently( + psbt, + fixture, + strangerHash, + keyPair.publicKey, + signature, + ), + ); + }); + + it('can be attached to the psbt as a tapScriptSig and serialized', () => { + const keyPair = ECPair.makeRandom({ rng }); + const fixture = buildFixture(keyPair); + + const psbt = buildPsbt(fixture); + const signature = generateTaprootScriptHashSignature( + psbt, + 0, + keyPair, + fixture.leafHash, + ); + attach(psbt, keyPair, fixture.leafHash, signature); + + // tapScriptSig needs no script, so it round trips through the wire format. + const reloaded = Psbt.fromBase64(psbt.toBase64(), { network }); + assert.deepStrictEqual( + reloaded.data.inputs[0].tapScriptSig![0].signature, + signature, + ); + assert.deepStrictEqual( + reloaded.data.inputs[0].tapScriptSig![0].leafHash, + fixture.leafHash, + ); + }); + + it('can not be checked by validateSignaturesOfInput without the script', () => { + // Documents a real limitation: validation derives its candidate hashes from + // input.tapLeafScript, so with no script on the input there is nothing to + // check against, even though tapScriptSig carries the leaf hash. + const keyPair = ECPair.makeRandom({ rng }); + const fixture = buildFixture(keyPair); + + const psbt = buildPsbt(fixture); + const signature = generateTaprootScriptHashSignature( + psbt, + 0, + keyPair, + fixture.leafHash, + ); + attach(psbt, keyPair, fixture.leafHash, signature); + + assert.throws( + () => psbt.validateSignaturesOfInput(0, validator), + /No signatures for this pubkey/, + ); + + // Supplying the script afterwards makes it verifiable again. + psbt.updateInput(0, { + tapLeafScript: [ + { + leafVersion: LEAF_VERSION_TAPSCRIPT, + script: fixture.leafScript, + controlBlock: fixture.controlBlock, + }, + ], + }); + assert.ok(psbt.validateSignaturesOfInput(0, validator)); + }); + + it('fails to finalize when only the hash was provided', () => { + const keyPair = ECPair.makeRandom({ rng }); + const fixture = buildFixture(keyPair); + + const psbt = buildPsbt(fixture); + const signature = generateTaprootScriptHashSignature( + psbt, + 0, + keyPair, + fixture.leafHash, + ); + attach(psbt, keyPair, fixture.leafHash, signature); + // The signature is present; only the script is missing. + assert.strictEqual(psbt.data.inputs[0].tapScriptSig!.length, 1); + + assert.throws( + () => psbt.finalizeInput(0), + /Signature for tapleaf script not found/, + ); + assert.throws( + () => psbt.finalizeAllInputs(), + /Signature for tapleaf script not found/, + ); + assert.throws( + () => psbt.finalizeTaprootInput(0), + /Signature for tapleaf script not found/, + ); + + // A failed finalize must leave the input untouched and unextractable. + assert.strictEqual(psbt.data.inputs[0].finalScriptWitness, undefined); + assert.strictEqual(psbt.data.inputs[0].finalScriptSig, undefined); + assert.throws(() => psbt.extractTransaction(), /Not finalized/); + }); + + it('finalizes once the script is supplied alongside the signature', () => { + const keyPair = ECPair.makeRandom({ rng }); + const fixture = buildFixture(keyPair); + + const psbt = buildPsbt(fixture); + const signature = generateTaprootScriptHashSignature( + psbt, + 0, + keyPair, + fixture.leafHash, + ); + attach(psbt, keyPair, fixture.leafHash, signature); + psbt.updateInput(0, { + tapLeafScript: [ + { + leafVersion: LEAF_VERSION_TAPSCRIPT, + script: fixture.leafScript, + controlBlock: fixture.controlBlock, + }, + ], + }); + psbt.finalizeInput(0); + + const witness = psbt.data.inputs[0].finalScriptWitness!; + assert.ok(witness); + assert.ok(tools.toHex(witness).includes(tools.toHex(fixture.leafScript))); + }); + + it('honours a non-default sighash type', () => { + const keyPair = ECPair.makeRandom({ rng }); + const fixture = buildFixture(keyPair); + + const psbt = buildPsbt(fixture); + psbt.updateInput(0, { sighashType: Transaction.SIGHASH_ALL }); + + assert.throws( + () => + generateTaprootScriptHashSignature(psbt, 0, keyPair, fixture.leafHash), + /Sighash type is not allowed/, + ); + + const signature = generateTaprootScriptHashSignature( + psbt, + 0, + keyPair, + fixture.leafHash, + [Transaction.SIGHASH_ALL], + ); + // 64 byte schnorr signature plus the explicit sighash byte. + assert.strictEqual(signature.length, 65); + assert.strictEqual(signature[64], Transaction.SIGHASH_ALL); + assert.ok( + verifyIndependently( + psbt, + fixture, + fixture.leafHash, + keyPair.publicKey, + signature, + Transaction.SIGHASH_ALL, + ), + ); + }); + + it('rejects bad arguments', () => { + const keyPair = ECPair.makeRandom({ rng }); + const fixture = buildFixture(keyPair); + const psbt = buildPsbt(fixture); + + assert.throws( + () => + generateTaprootScriptHashSignature( + psbt, + 0, + keyPair, + new Uint8Array(31), + ), + /Need a 32 byte tapleaf hash/, + ); + assert.throws( + () => + generateTaprootScriptHashSignature(psbt, 5, keyPair, fixture.leafHash), + /No input #5/, + ); + assert.throws( + () => + generateTaprootScriptHashSignature( + psbt, + 0, + { publicKey: keyPair.publicKey, sign: () => new Uint8Array(64) }, + fixture.leafHash, + ), + /Need Schnorr Signer/, + ); + }); + + it('is also available as a Psbt method', () => { + const keyPair = ECPair.makeRandom({ rng }); + const fixture = buildFixture(keyPair); + + const viaFn = buildPsbt(fixture); + const viaMethod = buildPsbt(fixture); + + assert.deepStrictEqual( + viaMethod.generateTaprootScriptHashSignature( + 0, + keyPair, + fixture.leafHash, + ), + generateTaprootScriptHashSignature(viaFn, 0, keyPair, fixture.leafHash), + ); + }); + + it('rejects a non-taproot input', () => { + const keyPair = ECPair.makeRandom({ rng }); + const fixture = buildFixture(keyPair); + const p2wpkh = payments.p2wpkh({ + pubkey: keyPair.publicKey, + network, + }); + + const psbt = new Psbt({ network }); + psbt.addInput({ + hash: tools.toHex(new Uint8Array(32).fill(7)), + index: 0, + witnessUtxo: { value: BigInt(AMOUNT), script: p2wpkh.output! }, + }); + psbt.addOutput({ + value: BigInt(AMOUNT - 1e4), + address: payments.p2tr({ + internalPubkey: fixture.internalPubkey, + network, + }).address!, + }); + + assert.throws( + () => + generateTaprootScriptHashSignature(psbt, 0, keyPair, fixture.leafHash), + /is not of type Taproot/, + ); + }); +}); diff --git a/ts_src/index.ts b/ts_src/index.ts index 2a4cb95da..a956f32c7 100644 --- a/ts_src/index.ts +++ b/ts_src/index.ts @@ -18,6 +18,7 @@ export { HDSigner, HDSignerAsync, toXOnly, + generateTaprootScriptHashSignature, } from './psbt.js'; /** @hidden */ export { OPS as opcodes } from './ops.js'; diff --git a/ts_src/psbt.ts b/ts_src/psbt.ts index f072f1d48..c0b18f05f 100644 --- a/ts_src/psbt.ts +++ b/ts_src/psbt.ts @@ -844,6 +844,88 @@ export class Psbt { throw new Error(`Input #${inputIndex} is not of type Taproot.`); } + /** + * Generates a taproot script-path signature for a leaf whose script is not + * known, given only that leaf's BIP341 tapleaf hash. The signature is + * returned; nothing is written to this PSBT. + * + * `scriptHash` is the tapleaf hash, i.e. + * `taggedHash('TapLeaf', leafVersion || compactSize(script) || script)` - the + * exact value `tapleafHash({ output: script, version: leafVersion })` returns, + * and the value the BIP341 script-path sighash commits to. It is NOT a plain + * hash of the script bytes. The leaf version is already committed to inside + * it, and the control block plays no part in the signature, so neither is + * needed here. + * + * The signature is returned rather than written to the PSBT. To attach it, + * put it in a `tapScriptSig` - a field that carries only the pubkey, the + * signature and the leaf hash, so no script is needed: + * + * ``` + * psbt.updateInput(inputIndex, { + * tapScriptSig: [{ + * pubkey: toXOnly(keyPair.publicKey), + * signature, + * leafHash: scriptHash, + * }], + * }); + * ``` + * + * WARNING: this is a blind signature. Nothing here can check that your pubkey + * appears in the leaf, that the leaf belongs to the taptree being spent, or + * what the script authorises. Every guard the normal `signInput` path relies + * on comes from reading the script. Only use this when the leaf is + * authenticated by some other means. + * + * @param inputIndex the position of the PSBT input. + * @param keyPair the Schnorr signer. + * @param scriptHash the BIP341 tapleaf hash of the leaf being spent. + * @param sighashTypes whitelist of allowed sighash types. + * @returns the taproot signature, with the sighash byte appended unless the + * sighash type is SIGHASH_DEFAULT. + */ + generateTaprootScriptHashSignature( + inputIndex: number, + keyPair: Signer, + scriptHash: Uint8Array, + sighashTypes: number[] = [Transaction.SIGHASH_DEFAULT], + ): Uint8Array { + if (!keyPair || !keyPair.publicKey) + throw new Error('Need Signer to sign input'); + if (typeof keyPair.signSchnorr !== 'function') + throw new Error( + `Need Schnorr Signer to sign taproot input #${inputIndex}.`, + ); + if (!(scriptHash instanceof Uint8Array) || scriptHash.length !== 32) + throw new Error( + `Need a 32 byte tapleaf hash to sign input #${inputIndex}.`, + ); + + const input = checkForInput(this.data.inputs, inputIndex); + const sighashType = input.sighashType || Transaction.SIGHASH_DEFAULT; + checkSighashTypeAllowed(sighashType, sighashTypes); + + // BIP341 commits to every prevout, not just this one. + const prevOuts: Output[] = this.data.inputs.map((i, index) => + getScriptAndAmountFromUtxo(index, i, this.__CACHE), + ); + if (!isP2TR(prevOuts[inputIndex].script)) + throw new Error(`Input #${inputIndex} is not of type Taproot.`); + + const hash = this.__CACHE.__TX.hashForWitnessV1( + inputIndex, + prevOuts.map(o => o.script), + prevOuts.map(o => o.value), + sighashType, + scriptHash, + ); + + return serializeTaprootSignature( + keyPair.signSchnorr(hash), + input.sighashType, + ); + } + private _signInput( inputIndex: number, keyPair: Signer, @@ -1143,6 +1225,49 @@ export class Psbt { } } +/** + * Generates a taproot script-path signature for a leaf whose script is not + * known, given only that leaf's BIP341 tapleaf hash. The signature is returned; + * the PSBT is not modified. + * + * The whole `psbt` is required, not just the input being signed: the BIP341 + * sighash commits to every prevout, and - depending on the sighash type - to the + * outputs as well. So the PSBT must already hold its inputs and outputs. + * + * `scriptHash` is the tapleaf hash, i.e. + * `taggedHash('TapLeaf', leafVersion || compactSize(script) || script)`. The + * leaf version is already committed to inside it, and the control block plays no + * part in the signature, so neither is passed here. + * + * WARNING: this is a blind signature. Nothing here can check that your pubkey + * appears in the leaf, that the leaf belongs to the taptree being spent, or what + * the script authorises. Every guard the normal signing path relies on comes + * from reading the script. Only use this when the leaf is authenticated by some + * other means. + * + * @param psbt the PSBT, with its inputs and outputs already added. + * @param inputIndex the position of the input to sign. + * @param keyPair the Schnorr signer. + * @param scriptHash the BIP341 tapleaf hash of the leaf being spent. + * @param sighashTypes whitelist of allowed sighash types. + * @returns the taproot signature. The PSBT is not modified; attach it yourself + * with `updateInput(inputIndex, { tapScriptSig: [...] })` if you want it there. + */ +export function generateTaprootScriptHashSignature( + psbt: Psbt, + inputIndex: number, + keyPair: Signer, + scriptHash: Uint8Array, + sighashTypes?: number[], +): Uint8Array { + return psbt.generateTaprootScriptHashSignature( + inputIndex, + keyPair, + scriptHash, + sighashTypes, + ); +} + interface PsbtCache { __NON_WITNESS_UTXO_TX_CACHE: Transaction[]; __NON_WITNESS_UTXO_BUF_CACHE: Uint8Array[];