diff --git a/package-lock.json b/package-lock.json index 63ee5fd8f..f073af945 100644 --- a/package-lock.json +++ b/package-lock.json @@ -823,6 +823,14 @@ "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", "dev": true }, + "fastpriorityqueue": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/fastpriorityqueue/-/fastpriorityqueue-0.7.1.tgz", + "integrity": "sha512-XJ+vbiXYjmxc32VEpXScAq7mBg3vqh90OjLfiuyQ0zAtXpgICdVgGjKHep1kLGQufyuCBiEYpl6ZKcw79chTpA==", + "requires": { + "minimist": "^1.2.5" + } + }, "file-uri-to-path": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", @@ -1438,8 +1446,7 @@ "minimist": { "version": "1.2.5", "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.5.tgz", - "integrity": "sha512-FM9nNUYrRBAELZQT3xeZQ7fmMOBg6nWNmJKTcgsJeaLstP/UODVpGsr5OhXhhXg6f+qtJ8uiZ+PUxkDWcgIXLw==", - "dev": true + "integrity": "sha512-FM9nNUYrRBAELZQT3xeZQ7fmMOBg6nWNmJKTcgsJeaLstP/UODVpGsr5OhXhhXg6f+qtJ8uiZ+PUxkDWcgIXLw==" }, "mkdirp": { "version": "0.5.3", diff --git a/package.json b/package.json index 9d19a3e2f..c74cd3f84 100644 --- a/package.json +++ b/package.json @@ -57,6 +57,7 @@ "bs58check": "^2.0.0", "create-hash": "^1.1.0", "create-hmac": "^1.1.3", + "fastpriorityqueue": "^0.7.1", "merkle-lib": "^2.0.10", "pushdata-bitcoin": "^1.0.1", "randombytes": "^2.0.1", diff --git a/src/payments/index.js b/src/payments/index.js index ddab97768..3235848c7 100644 --- a/src/payments/index.js +++ b/src/payments/index.js @@ -10,6 +10,8 @@ const p2pkh_1 = require('./p2pkh'); exports.p2pkh = p2pkh_1.p2pkh; const p2sh_1 = require('./p2sh'); exports.p2sh = p2sh_1.p2sh; +const p2tr_1 = require('./p2tr'); +exports.p2tr = p2tr_1.p2tr; const p2wpkh_1 = require('./p2wpkh'); exports.p2wpkh = p2wpkh_1.p2wpkh; const p2wsh_1 = require('./p2wsh'); diff --git a/src/payments/p2tr.js b/src/payments/p2tr.js new file mode 100644 index 000000000..3ea093645 --- /dev/null +++ b/src/payments/p2tr.js @@ -0,0 +1,72 @@ +'use strict'; +Object.defineProperty(exports, '__esModule', { value: true }); +const networks_1 = require('../networks'); +const bscript = require('../script'); +const taproot = require('../taproot'); +const lazy = require('./lazy'); +const typef = require('typeforce'); +const OPS = bscript.OPS; +const ecc = require('tiny-secp256k1'); +const { bech32m } = require('bech32'); +/** Internal key with unknown discrete logarithm for eliminating keypath spends */ +const H = Buffer.from( + '50929b74c1a04954b78b4b6035e97a5e078a5a0f28ec96d547bfee9ace803ac0', + 'hex', +); +// output: OP_1 {witnessProgram} +function p2tr(a, opts) { + if (!a.address && !a.pubkey && !a.pubkeys && !a.scripts && !a.output) + throw new TypeError('Not enough data'); + opts = Object.assign({ validate: true }, opts || {}); + typef( + { + network: typef.maybe(typef.Object), + address: typef.maybe(typef.String), + output: typef.maybe(typef.BufferN(34)), + // a single pubkey + pubkey: typef.maybe(ecc.isPoint), + // the pub keys used for aggregate musig signing + pubkeys: typef.maybe(typef.arrayOf(ecc.isPoint)), + scripts: typef.maybe(typef.arrayOf(typef.Buffer)), + weights: typef.maybe(typef.arrayOf(typef.Number)), + }, + a, + ); + const network = a.network || networks_1.bitcoin; + const o = { network }; + lazy.prop(o, 'address', () => { + if (!o.output) return; + const words = bech32m.toWords(o.output.slice(2)); + words.unshift(0x01); + return bech32m.encode(network.bech32, words); + }); + lazy.prop(o, 'output', () => { + let internalPubkey; + if (a.pubkey) { + // single pubkey + // internalPubkey = taproot.trimFirstByte(a.pubkey); + internalPubkey = a.pubkey; + } else if (a.pubkeys && a.pubkeys.length) { + // multiple pubkeys + internalPubkey = taproot.aggregateMuSigPubkeys(a.pubkeys); + } else { + // no key path spends + if (!a.scripts) return; // must have either scripts or pubkey(s) + // use internal key with unknown secret key + internalPubkey = H; + } + let tapTreeRoot; + if (a.scripts) { + tapTreeRoot = taproot.getHuffmanTaptreeRoot(a.scripts, a.weights); + } + const taprootPubkey = taproot.tapTweakPubkey(internalPubkey, tapTreeRoot); + // OP_1 indicates segwit version 1 + return bscript.compile([OPS.OP_1, taprootPubkey]); + }); + lazy.prop(o, 'name', () => { + const nameParts = ['p2tr']; + return nameParts.join('-'); + }); + return Object.assign(o, a); +} +exports.p2tr = p2tr; diff --git a/src/taproot.js b/src/taproot.js new file mode 100644 index 000000000..854fb87c1 --- /dev/null +++ b/src/taproot.js @@ -0,0 +1,173 @@ +'use strict'; +Object.defineProperty(exports, '__esModule', { value: true }); +const assert = require('assert'); +const FastPriorityQueue = require('fastpriorityqueue'); +const bcrypto = require('./crypto'); +const ecc = require('tiny-secp256k1'); +// const SECP256K1_ORDER = Buffer.from('fffffffffffffffffffffffffffffffebaaedce6af48a03bbfd25e8cd0364141', 'hex') +const INITIAL_TAPSCRIPT_VERSION = Buffer.from('c0', 'hex'); +const TAPLEAF_TAGGED_HASH = bcrypto.sha256(Buffer.from('TapLeaf')); +const TAPBRANCH_TAGGED_HASH = bcrypto.sha256(Buffer.from('TapBranch')); +const TAPTWEAK_TAGGED_HASH = bcrypto.sha256(Buffer.from('TapTweak')); +/** + * Trims the leading 02/03 byte from an ECDSA pub key to get a 32 byte schnorr + * pub key with x-only coordinates. + * @param pubkey A 33 byte pubkey representing an EC point + * @returns a 32 byte x-only coordinate + */ +function trimFirstByte(pubkey) { + assert(pubkey.length === 33); + return pubkey.slice(1, 33); +} +exports.trimFirstByte = trimFirstByte; +/** + * Aggregates a list of public keys into a single MuSig public key + * according to the MuSig paper. + * @param pubkeys The list of pub keys to aggregate + * @returns a 32 byte Buffer representing the aggregate key + */ +function aggregateMuSigPubkeys(pubkeys) { + // sort keys in ascending order + pubkeys.sort(); + const trimmedPubkeys = []; + pubkeys.forEach(pubkey => { + const trimmedPubkey = trimFirstByte(pubkey); + trimmedPubkeys.push(trimmedPubkey); + }); + // In MuSig all signers contribute key material to a single signing key, + // using the equation + // + // P = sum_i µ_i * P_i + // + // where `P_i` is the public key of the `i`th signer and `µ_i` is a so-called + // _MuSig coefficient_ computed according to the following equation + // + // L = H(P_1 || P_2 || ... || P_n) + // µ_i = H(L || i) + const L = bcrypto.sha256(Buffer.concat(trimmedPubkeys)); + let aggregatePubkey; + pubkeys.forEach(pubkey => { + const trimmedPubkey = trimFirstByte(pubkey); + const c = bcrypto.sha256(Buffer.concat([L, trimmedPubkey])); + const tweakedPubkey = ecc.pointMultiply(pubkey, c); + if (aggregatePubkey === undefined) { + aggregatePubkey = tweakedPubkey; + } else { + aggregatePubkey = ecc.pointAdd(aggregatePubkey, tweakedPubkey); + } + }); + return aggregatePubkey; +} +exports.aggregateMuSigPubkeys = aggregateMuSigPubkeys; +/** + * Gets a tapleaf tagged hash from a script. + * @param script + * @returns + */ +function hashTapLeaf(script) { + // TODO: use multiple byte `size` when script length is >= 253 bytes + const size = new Uint8Array([script.length]); + return bcrypto.sha256( + Buffer.concat([ + TAPLEAF_TAGGED_HASH, + TAPLEAF_TAGGED_HASH, + INITIAL_TAPSCRIPT_VERSION, + size, + script, + ]), + ); +} +exports.hashTapLeaf = hashTapLeaf; +/** + * Creates a lexicographically sorted tapbranch from two child taptree nodes + * and returns its tagged hash. + * @param child1 + * @param child2 + * @returns the tagged tapbranch hash + */ +function hashTapBranch(child1, child2) { + let leftChild; + let rightChild; + // sort the children lexicographically + if (child1 < child2) { + leftChild = child1; + rightChild = child2; + } else { + leftChild = child2; + rightChild = child1; + } + return bcrypto.sha256( + Buffer.concat([ + TAPBRANCH_TAGGED_HASH, + TAPBRANCH_TAGGED_HASH, + leftChild, + rightChild, + ]), + ); +} +exports.hashTapBranch = hashTapBranch; +/** + * Tweaks an internal pubkey using the tagged hash of a taptree root. + * @param pubkey the internal pubkey to tweak + * @param tapTreeRoot the taptree root tagged hash + * @returns the tweaked pubkey + */ +function tapTweakPubkey(pubkey, tapTreeRoot) { + let tweakedPubkey; + if (tapTreeRoot) { + const trimmedPubkey = trimFirstByte(pubkey); + const tapTweak = bcrypto.sha256( + Buffer.concat([ + TAPTWEAK_TAGGED_HASH, + TAPTWEAK_TAGGED_HASH, + trimmedPubkey, + tapTreeRoot, + ]), + ); + tweakedPubkey = ecc.pointAddScalar(pubkey, tapTweak); + } else { + // If the spending conditions do not require a script path, the output key should commit to an + // unspendable script path instead of having no script path. + const unspendableScriptPathRoot = bcrypto.sha256(pubkey); + tweakedPubkey = ecc.pointAddScalar(pubkey, unspendableScriptPathRoot); + } + return trimFirstByte(tweakedPubkey); +} +exports.tapTweakPubkey = tapTweakPubkey; +/** + * Gets the root hash of a taptree using a weighted Huffman construction from a + * list of scripts and corresponding weights, + * @param scripts + * @param weights + * @returns the tagged hash of the taptree root + */ +function getHuffmanTaptreeRoot(scripts, weights) { + const weightedScripts = []; + scripts.forEach((script, index) => { + const weight = weights ? weights[index] || 1 : 1; + assert(weight > 0); + assert(Number.isInteger(weight)); + weightedScripts.push({ + weight, + taggedHash: hashTapLeaf(script), + }); + }); + const queue = new FastPriorityQueue((a, b) => { + return a.weight < b.weight; + }); + weightedScripts.forEach(weightedScript => { + queue.add(weightedScript); + }); + while (queue.size > 1) { + const child1 = queue.poll(); + const child2 = queue.poll(); + const branchHash = hashTapBranch(child1.taggedHash, child2.taggedHash); + queue.add({ + taggedHash: branchHash, + weight: child1.weight + child2.weight, + }); + } + const tapTreeHash = queue.poll().taggedHash; + return tapTreeHash; +} +exports.getHuffmanTaptreeRoot = getHuffmanTaptreeRoot; diff --git a/test/fixtures/p2tr.json b/test/fixtures/p2tr.json new file mode 100644 index 000000000..204e9e533 --- /dev/null +++ b/test/fixtures/p2tr.json @@ -0,0 +1,70 @@ +{ + "valid": [ + { + "description": "p2tr, out (from scripts & key)", + "arguments": { + "pubkey": "03af455f4989d122e9185f8c351dbaecd13adca3eef8a9d38ef8ffed6867e342e3", + "scripts": [ + "208f5173bc367914e1574aceb3c7232a178a764fb6f14730b6b20bd36394c6c717ac", + "2007c7c32d159a27ba1824798b3b1d11e1b85f4dbc9e9fe63d95440a30737496deac", + "204d4b27ab455a6e2b03af29a141ef47fc579c8435f563c065bf0dd12e6180ccd4ac" + ], + "weights": [1, 1, 2], + "network": "regtest" + }, + "options": {}, + "expected": { + "name": "p2tr", + "output": "OP_1 a64b94fdd14d11d268ae3aee9669e5489984ec326bc5c593fc1ae28ec9057cab", + "address": "bcrt1p5e9eflw3f5gay69w8thfv609fzvcfmpjd0zutylurt3gajg90j4stfe7x0" + } + }, + { + "description": "p2tr, out (from scripts & aggregate key)", + "arguments": { + "pubkeys": [ + "020e5bf9b7123091a300382fb400565a1867e839b24654c4138c9093a82fd58459", + "02dbec3eee44f474af6751e0a453b427ff610b9cc7f79b80f60337aeb06761c394" + ], + "scripts": [ + "200e5bf9b7123091a300382fb400565a1867e839b24654c4138c9093a82fd58459ad20dbec3eee44f474af6751e0a453b427ff610b9cc7f79b80f60337aeb06761c394ad", + "20dbec3eee44f474af6751e0a453b427ff610b9cc7f79b80f60337aeb06761c394ad200dcd7e6035f7ff5c860b78cfdd2bd80b4b160ca99a71654796afde11457e11e7ad", + "200dcd7e6035f7ff5c860b78cfdd2bd80b4b160ca99a71654796afde11457e11e7ad200e5bf9b7123091a300382fb400565a1867e839b24654c4138c9093a82fd58459ad" + ], + "weights": [1, 1, 2], + "network": "regtest" + }, + "options": {}, + "expected": { + "name": "p2tr", + "output": "OP_1 d3793a0e3a819a9eea4ab03691cc57157a1a7190fa2edbe077fe509cc7b499cb", + "address": "bcrt1p6dun5r36sxdfa6j2kqmfrnzhz4ap5uvslghdhcrhlegfe3a5n89s3ulntt" + } + }, + { + "description": "p2tr, address from output", + "arguments": { + "output": "OP_1 618d4140bbf980976a0f4d2ff9bb05a6772866840770452ff405148b872f0dc8", + "network": "regtest" + }, + "options": {}, + "expected": { + "name": "p2tr", + "address": "bcrt1pvxx5zs9mlxqfw6s0f5hlnwc95emjse5yqacy2tl5q52ghpe0phyqzwzvwu" + } + }, + { + "description": "p2tr, testnet address from output", + "arguments": { + "output": "OP_1 d5e89e0b73605abba690ba5e00484e279d006283bed0055a0530fb6a8c9adac7", + "network": "testnet" + }, + "options": {}, + "expected": { + "name": "p2tr", + "address": "tb1p6h5fuzmnvpdthf5shf0qqjzwy7wsqc5rhmgq2ks9xrak4ry6mtrscsqvzp" + } + } + ], + "invalid": [] +} diff --git a/test/payments.spec.ts b/test/payments.spec.ts index bc123cba3..c7693b11a 100644 --- a/test/payments.spec.ts +++ b/test/payments.spec.ts @@ -2,112 +2,116 @@ import * as assert from 'assert'; import { describe, it } from 'mocha'; import { PaymentCreator } from '../src/payments'; import * as u from './payments.utils'; -['embed', 'p2ms', 'p2pk', 'p2pkh', 'p2sh', 'p2wpkh', 'p2wsh'].forEach(p => { - describe(p, () => { - let fn: PaymentCreator; - const payment = require('../src/payments/' + p); - if (p === 'embed') { - fn = payment.p2data; - } else { - fn = payment[p]; - } - const fixtures = require('./fixtures/' + p); +['embed', 'p2ms', 'p2pk', 'p2pkh', 'p2sh', 'p2tr', 'p2wpkh', 'p2wsh'].forEach( + p => { + describe(p, () => { + let fn: PaymentCreator; + const payment = require('../src/payments/' + p); + if (p === 'embed') { + fn = payment.p2data; + } else { + fn = payment[p]; + } + const fixtures = require('./fixtures/' + p); - fixtures.valid.forEach((f: any) => { - it(f.description + ' as expected', () => { - const args = u.preform(f.arguments); - const actual = fn(args, f.options); + fixtures.valid.forEach((f: any) => { + it(f.description + ' as expected', () => { + const args = u.preform(f.arguments); + const actual = fn(args, f.options); - u.equate(actual, f.expected, f.arguments); - }); + u.equate(actual, f.expected, f.arguments); + }); - it(f.description + ' as expected (no validation)', () => { - const args = u.preform(f.arguments); - const actual = fn( - args, - Object.assign({}, f.options, { - validate: false, - }), - ); + it(f.description + ' as expected (no validation)', () => { + const args = u.preform(f.arguments); + const actual = fn( + args, + Object.assign({}, f.options, { + validate: false, + }), + ); - u.equate(actual, f.expected, f.arguments); + u.equate(actual, f.expected, f.arguments); + }); }); - }); - fixtures.invalid.forEach((f: any) => { - it( - 'throws ' + f.exception + (f.description ? 'for ' + f.description : ''), - () => { - const args = u.preform(f.arguments); + fixtures.invalid.forEach((f: any) => { + it( + 'throws ' + + f.exception + + (f.description ? 'for ' + f.description : ''), + () => { + const args = u.preform(f.arguments); - assert.throws(() => { - fn(args, f.options); - }, new RegExp(f.exception)); - }, - ); - }); + assert.throws(() => { + fn(args, f.options); + }, new RegExp(f.exception)); + }, + ); + }); - if (p === 'p2sh') { - const p2wsh = require('../src/payments/p2wsh').p2wsh; - const p2pk = require('../src/payments/p2pk').p2pk; - it('properly assembles nested p2wsh with names', () => { - const actual = fn({ - redeem: p2wsh({ - redeem: p2pk({ - pubkey: Buffer.from( - '03e15819590382a9dd878f01e2f0cbce541564eb415e43b440472d883ecd283058', - 'hex', - ), + if (p === 'p2sh') { + const p2wsh = require('../src/payments/p2wsh').p2wsh; + const p2pk = require('../src/payments/p2pk').p2pk; + it('properly assembles nested p2wsh with names', () => { + const actual = fn({ + redeem: p2wsh({ + redeem: p2pk({ + pubkey: Buffer.from( + '03e15819590382a9dd878f01e2f0cbce541564eb415e43b440472d883ecd283058', + 'hex', + ), + }), }), - }), + }); + assert.strictEqual( + actual.address, + '3MGbrbye4ttNUXM8WAvBFRKry4fkS9fjuw', + ); + assert.strictEqual(actual.name, 'p2sh-p2wsh-p2pk'); + assert.strictEqual(actual.redeem!.name, 'p2wsh-p2pk'); + assert.strictEqual(actual.redeem!.redeem!.name, 'p2pk'); }); - assert.strictEqual( - actual.address, - '3MGbrbye4ttNUXM8WAvBFRKry4fkS9fjuw', - ); - assert.strictEqual(actual.name, 'p2sh-p2wsh-p2pk'); - assert.strictEqual(actual.redeem!.name, 'p2wsh-p2pk'); - assert.strictEqual(actual.redeem!.redeem!.name, 'p2pk'); - }); - } + } - // cross-verify dynamically too - if (!fixtures.dynamic) return; - const { depends, details } = fixtures.dynamic; + // cross-verify dynamically too + if (!fixtures.dynamic) return; + const { depends, details } = fixtures.dynamic; - details.forEach((f: any) => { - const detail = u.preform(f); - const disabled: any = {}; - if (f.disabled) - f.disabled.forEach((k: string) => { - disabled[k] = true; - }); + details.forEach((f: any) => { + const detail = u.preform(f); + const disabled: any = {}; + if (f.disabled) + f.disabled.forEach((k: string) => { + disabled[k] = true; + }); - for (const key in depends) { - if (key in disabled) continue; - const dependencies = depends[key]; + for (const key in depends) { + if (key in disabled) continue; + const dependencies = depends[key]; - dependencies.forEach((dependency: any) => { - if (!Array.isArray(dependency)) dependency = [dependency]; + dependencies.forEach((dependency: any) => { + if (!Array.isArray(dependency)) dependency = [dependency]; - const args = {}; - dependency.forEach((d: any) => { - u.from(d, detail, args); - }); - const expected = u.from(key, detail); + const args = {}; + dependency.forEach((d: any) => { + u.from(d, detail, args); + }); + const expected = u.from(key, detail); - it( - f.description + - ', ' + - key + - ' derives from ' + - JSON.stringify(dependency), - () => { - u.equate(fn(args), expected); - }, - ); - }); - } + it( + f.description + + ', ' + + key + + ' derives from ' + + JSON.stringify(dependency), + () => { + u.equate(fn(args), expected); + }, + ); + }); + } + }); }); - }); -}); + }, +); diff --git a/test/payments.utils.ts b/test/payments.utils.ts index c0635f3cf..8eff93018 100644 --- a/test/payments.utils.ts +++ b/test/payments.utils.ts @@ -146,6 +146,7 @@ export function preform(x: any): any { if (x.redeem.network) x.redeem.network = (BNETWORKS as any)[x.redeem.network]; } + if (x.scripts) x.scripts = x.scripts.map(fromHex); return x; } diff --git a/test/taproot.spec.ts b/test/taproot.spec.ts new file mode 100644 index 000000000..4ed10c52b --- /dev/null +++ b/test/taproot.spec.ts @@ -0,0 +1,110 @@ +import * as assert from 'assert'; +import { describe, it } from 'mocha'; +import * as bcrypto from '../src/crypto'; +import * as taproot from '../src/taproot'; +import { ECPair } from '..'; +export const OPS = require('bitcoin-ops') as { [index: string]: number }; + +describe('taproot utils', () => { + const internalPubkey = Buffer.from( + '03af455f4989d122e9185f8c351dbaecd13adca3eef8a9d38ef8ffed6867e342e3', + 'hex', + ); + + it('aggregates pubkeys', () => { + // example taken from bitcoinops taproot workshop musig exercise + const key1 = ECPair.fromPrivateKey(bcrypto.sha256(Buffer.from('key0'))); + const key2 = ECPair.fromPrivateKey(bcrypto.sha256(Buffer.from('key1'))); + const key3 = ECPair.fromPrivateKey(bcrypto.sha256(Buffer.from('key2'))); + + const aggregatePubkey = taproot.aggregateMuSigPubkeys([ + key1.publicKey, + key2.publicKey, + key3.publicKey, + ]); + + assert.strictEqual( + taproot.trimFirstByte(aggregatePubkey).toString('hex'), + 'eeeea7d79f3ecde08d2a3c59f40eb3adcac9defb77d3b92053e5df95165139cd', + ); + }); + + it('hashes a tap leaf', () => { + const pubkey = Buffer.from( + '3627a049c3dd937b1ef01432a54f2e31642be754764f5a677c174576fb02571e', + 'hex', + ); + + const script = Buffer.concat([ + new Uint8Array([32]), // push 32 byte pub key + pubkey, + new Uint8Array([OPS.OP_CHECKSIG]), + ]); + + const tapLeafHash = taproot.hashTapLeaf(script); + + assert.strictEqual( + tapLeafHash.toString('hex'), + '17e20b19dc7e8093c4278d3bf42447a2334546f874ba1693c9d7bc4d81db15c4', + ); + }); + + it('hashes a tap branch', () => { + const child1 = Buffer.from( + 'f248f2fee0977d141e19e0fddae1cfcdcede1a34a77ebc53c8fe96f346c7f7fc', + 'hex', + ); + const child2 = Buffer.from( + '72e4cc6e974cae355cf72476edeff8e9a2877ad67cfa4f12bad6f178c6918b9c', + 'hex', + ); + + const tapBranchHash = taproot.hashTapBranch(child1, child2); + + assert.strictEqual( + tapBranchHash.toString('hex'), + '3009565ab85ceb87d3dfdedc469ec205b2ea139a148af1dcbcc1addf8f1b68a4', + ); + }); + + it('taptweaks a pubkey', () => { + const tapTreeRoot = Buffer.from( + 'dde870346c0f5f1f1c2341041520baa4e252723474c6969f432c2af98251ac01', + 'hex', + ); + + const taprootPubkey = taproot.tapTweakPubkey(internalPubkey, tapTreeRoot); + + assert.strictEqual( + taprootPubkey.toString('hex'), + '8634eebf6c81c7df86185eb16415674174dcdceb8e0a5f435eeb7941639fe7b9', + ); + }); + + it('builds a weighted taptree from scripts and tweaks a pubkey with it', () => { + const scriptA = Buffer.from( + '2052b319d011c12225b8f9c63349e7b0e78118a1cb7e406fc70e3e08862b49d10aac', + 'hex', + ); + const scriptB = Buffer.from( + '20622e61f750f10e597b18a3bb4e5dea88548508b8cb37bfc0fb7af20f7a417d6aac', + 'hex', + ); + const scriptC = Buffer.from( + '2092a7d17376802e183fc49fb93d4c9b0a4d1cf845c005debbcc9cd57550a6f617ac', + 'hex', + ); + + const tapTreeRoot = taproot.getHuffmanTaptreeRoot( + [scriptA, scriptB, scriptC], + [1, 1, 2], + ); + + const taprootPubkey = taproot.tapTweakPubkey(internalPubkey, tapTreeRoot); + + assert.strictEqual( + taprootPubkey.toString('hex'), + 'd32c6fd13ddc1544528fd60e5c0d7a1ad0ea914fb0423b05bbb72cdc5e5dd220', + ); + }); +}); diff --git a/ts_src/payments/index.ts b/ts_src/payments/index.ts index 4b7f1117e..af2adcdd3 100644 --- a/ts_src/payments/index.ts +++ b/ts_src/payments/index.ts @@ -4,6 +4,7 @@ import { p2ms } from './p2ms'; import { p2pk } from './p2pk'; import { p2pkh } from './p2pkh'; import { p2sh } from './p2sh'; +import { p2tr } from './p2tr'; import { p2wpkh } from './p2wpkh'; import { p2wsh } from './p2wsh'; @@ -23,6 +24,8 @@ export interface Payment { hash?: Buffer; redeem?: Payment; witness?: Buffer[]; + scripts?: Buffer[]; + weights?: number[]; } export type PaymentCreator = (a: Payment, opts?: PaymentOpts) => Payment; @@ -38,7 +41,7 @@ export type StackElement = Buffer | number; export type Stack = StackElement[]; export type StackFunction = () => Stack; -export { embed, p2ms, p2pk, p2pkh, p2sh, p2wpkh, p2wsh }; +export { embed, p2ms, p2pk, p2pkh, p2sh, p2tr, p2wpkh, p2wsh }; // TODO // witness commitment diff --git a/ts_src/payments/p2tr.ts b/ts_src/payments/p2tr.ts new file mode 100644 index 000000000..dfc948a3d --- /dev/null +++ b/ts_src/payments/p2tr.ts @@ -0,0 +1,84 @@ +import { bitcoin as BITCOIN_NETWORK } from '../networks'; +import * as bscript from '../script'; +import * as taproot from '../taproot'; +import { Payment, PaymentOpts } from './index'; +import * as lazy from './lazy'; +const typef = require('typeforce'); +const OPS = bscript.OPS; +const ecc = require('tiny-secp256k1'); + +const { bech32m } = require('bech32'); + +/** Internal key with unknown discrete logarithm for eliminating keypath spends */ +const H = Buffer.from( + '50929b74c1a04954b78b4b6035e97a5e078a5a0f28ec96d547bfee9ace803ac0', + 'hex', +); + +// output: OP_1 {witnessProgram} +export function p2tr(a: Payment, opts?: PaymentOpts): Payment { + if (!a.address && !a.pubkey && !a.pubkeys && !a.scripts && !a.output) + throw new TypeError('Not enough data'); + opts = Object.assign({ validate: true }, opts || {}); + + typef( + { + network: typef.maybe(typef.Object), + + address: typef.maybe(typef.String), + output: typef.maybe(typef.BufferN(34)), + // a single pubkey + pubkey: typef.maybe(ecc.isPoint), + // the pub keys used for aggregate musig signing + pubkeys: typef.maybe(typef.arrayOf(ecc.isPoint)), + + scripts: typef.maybe(typef.arrayOf(typef.Buffer)), + weights: typef.maybe(typef.arrayOf(typef.Number)), + }, + a, + ); + + const network = a.network || BITCOIN_NETWORK; + + const o: Payment = { network }; + + lazy.prop(o, 'address', () => { + if (!o.output) return; + + const words = bech32m.toWords(o.output.slice(2)); + words.unshift(0x01); + return bech32m.encode(network.bech32, words); + }); + lazy.prop(o, 'output', () => { + let internalPubkey: Buffer; + if (a.pubkey) { + // single pubkey + // internalPubkey = taproot.trimFirstByte(a.pubkey); + internalPubkey = a.pubkey; + } else if (a.pubkeys && a.pubkeys.length) { + // multiple pubkeys + internalPubkey = taproot.aggregateMuSigPubkeys(a.pubkeys); + } else { + // no key path spends + if (!a.scripts) return; // must have either scripts or pubkey(s) + + // use internal key with unknown secret key + internalPubkey = H; + } + + let tapTreeRoot: Buffer | undefined; + if (a.scripts) { + tapTreeRoot = taproot.getHuffmanTaptreeRoot(a.scripts, a.weights); + } + const taprootPubkey = taproot.tapTweakPubkey(internalPubkey, tapTreeRoot); + + // OP_1 indicates segwit version 1 + return bscript.compile([OPS.OP_1, taprootPubkey]); + }); + lazy.prop(o, 'name', () => { + const nameParts = ['p2tr']; + return nameParts.join('-'); + }); + + return Object.assign(o, a); +} diff --git a/ts_src/taproot.ts b/ts_src/taproot.ts new file mode 100644 index 000000000..aae5f12f6 --- /dev/null +++ b/ts_src/taproot.ts @@ -0,0 +1,205 @@ +import assert = require('assert'); +import FastPriorityQueue = require('fastpriorityqueue'); +import * as bcrypto from './crypto'; +const ecc = require('tiny-secp256k1'); + +// const SECP256K1_ORDER = Buffer.from('fffffffffffffffffffffffffffffffebaaedce6af48a03bbfd25e8cd0364141', 'hex') +const INITIAL_TAPSCRIPT_VERSION = Buffer.from('c0', 'hex'); + +const TAPLEAF_TAGGED_HASH = bcrypto.sha256(Buffer.from('TapLeaf')); +const TAPBRANCH_TAGGED_HASH = bcrypto.sha256(Buffer.from('TapBranch')); +const TAPTWEAK_TAGGED_HASH = bcrypto.sha256(Buffer.from('TapTweak')); + +interface WeightedTapScript { + /** A TapLeaf or TapBranch tagged hash */ + taggedHash: Buffer; + weight: number; +} + +/** + * Trims the leading 02/03 byte from an ECDSA pub key to get a 32 byte schnorr + * pub key with x-only coordinates. + * @param pubkey A 33 byte pubkey representing an EC point + * @returns a 32 byte x-only coordinate + */ +export function trimFirstByte(pubkey: Buffer): Buffer { + assert(pubkey.length === 33); + return pubkey.slice(1, 33); +} + +/** + * Aggregates a list of public keys into a single MuSig public key + * according to the MuSig paper. + * @param pubkeys The list of pub keys to aggregate + * @returns a 32 byte Buffer representing the aggregate key + */ +export function aggregateMuSigPubkeys(pubkeys: Buffer[]): Buffer { + // sort keys in ascending order + pubkeys.sort(); + + const trimmedPubkeys: Buffer[] = []; + pubkeys.forEach(pubkey => { + const trimmedPubkey = trimFirstByte(pubkey); + trimmedPubkeys.push(trimmedPubkey); + }); + + // In MuSig all signers contribute key material to a single signing key, + // using the equation + // + // P = sum_i µ_i * P_i + // + // where `P_i` is the public key of the `i`th signer and `µ_i` is a so-called + // _MuSig coefficient_ computed according to the following equation + // + // L = H(P_1 || P_2 || ... || P_n) + // µ_i = H(L || i) + + const L = bcrypto.sha256(Buffer.concat(trimmedPubkeys)); + + let aggregatePubkey: Buffer; + pubkeys.forEach(pubkey => { + const trimmedPubkey = trimFirstByte(pubkey); + const c = bcrypto.sha256(Buffer.concat([L, trimmedPubkey])); + + const tweakedPubkey = ecc.pointMultiply(pubkey, c); + if (aggregatePubkey === undefined) { + aggregatePubkey = tweakedPubkey; + } else { + aggregatePubkey = ecc.pointAdd(aggregatePubkey, tweakedPubkey); + } + }); + + return aggregatePubkey!; +} + +/** + * Gets a tapleaf tagged hash from a script. + * @param script + * @returns + */ +export function hashTapLeaf(script: Buffer): Buffer { + // TODO: use multiple byte `size` when script length is >= 253 bytes + const size = new Uint8Array([script.length]); + + return bcrypto.sha256( + Buffer.concat([ + TAPLEAF_TAGGED_HASH, + TAPLEAF_TAGGED_HASH, + INITIAL_TAPSCRIPT_VERSION, + size, + script, + ]), + ); +} + +/** + * Creates a lexicographically sorted tapbranch from two child taptree nodes + * and returns its tagged hash. + * @param child1 + * @param child2 + * @returns the tagged tapbranch hash + */ +export function hashTapBranch(child1: Buffer, child2: Buffer): Buffer { + let leftChild: Buffer; + let rightChild: Buffer; + + // sort the children lexicographically + if (child1 < child2) { + leftChild = child1; + rightChild = child2; + } else { + leftChild = child2; + rightChild = child1; + } + + return bcrypto.sha256( + Buffer.concat([ + TAPBRANCH_TAGGED_HASH, + TAPBRANCH_TAGGED_HASH, + leftChild, + rightChild, + ]), + ); +} + +/** + * Tweaks an internal pubkey using the tagged hash of a taptree root. + * @param pubkey the internal pubkey to tweak + * @param tapTreeRoot the taptree root tagged hash + * @returns the tweaked pubkey + */ +export function tapTweakPubkey(pubkey: Buffer, tapTreeRoot?: Buffer): Buffer { + let tweakedPubkey: Buffer; + + if (tapTreeRoot) { + const trimmedPubkey = trimFirstByte(pubkey); + const tapTweak = bcrypto.sha256( + Buffer.concat([ + TAPTWEAK_TAGGED_HASH, + TAPTWEAK_TAGGED_HASH, + trimmedPubkey, + tapTreeRoot, + ]), + ); + + tweakedPubkey = ecc.pointAddScalar(pubkey, tapTweak); + } else { + // If the spending conditions do not require a script path, the output key should commit to an + // unspendable script path instead of having no script path. + const unspendableScriptPathRoot = bcrypto.sha256(pubkey); + + tweakedPubkey = ecc.pointAddScalar(pubkey, unspendableScriptPathRoot); + } + + return trimFirstByte(tweakedPubkey); +} + +/** + * Gets the root hash of a taptree using a weighted Huffman construction from a + * list of scripts and corresponding weights, + * @param scripts + * @param weights + * @returns the tagged hash of the taptree root + */ +export function getHuffmanTaptreeRoot( + scripts: Buffer[], + weights?: number[], +): Buffer { + const weightedScripts: WeightedTapScript[] = []; + + scripts.forEach((script, index) => { + const weight = weights ? weights[index] || 1 : 1; + assert(weight > 0); + assert(Number.isInteger(weight)); + + weightedScripts.push({ + weight, + taggedHash: hashTapLeaf(script), + }); + }); + + const queue = new FastPriorityQueue( + (a: WeightedTapScript, b: WeightedTapScript): boolean => { + return a.weight < b.weight; + }, + ); + + weightedScripts.forEach(weightedScript => { + queue.add(weightedScript); + }); + + while (queue.size > 1) { + const child1 = queue.poll()!; + const child2 = queue.poll()!; + + const branchHash = hashTapBranch(child1.taggedHash, child2.taggedHash); + queue.add({ + taggedHash: branchHash, + weight: child1.weight + child2.weight, + }); + } + + const tapTreeHash = queue.poll()!.taggedHash; + + return tapTreeHash; +} diff --git a/types/payments/index.d.ts b/types/payments/index.d.ts index 922e0bfd2..f96b3c2c3 100644 --- a/types/payments/index.d.ts +++ b/types/payments/index.d.ts @@ -4,6 +4,7 @@ import { p2ms } from './p2ms'; import { p2pk } from './p2pk'; import { p2pkh } from './p2pkh'; import { p2sh } from './p2sh'; +import { p2tr } from './p2tr'; import { p2wpkh } from './p2wpkh'; import { p2wsh } from './p2wsh'; export interface Payment { @@ -22,6 +23,8 @@ export interface Payment { hash?: Buffer; redeem?: Payment; witness?: Buffer[]; + scripts?: Buffer[]; + weights?: number[]; } export declare type PaymentCreator = (a: Payment, opts?: PaymentOpts) => Payment; export declare type PaymentFunction = () => Payment; @@ -32,4 +35,4 @@ export interface PaymentOpts { export declare type StackElement = Buffer | number; export declare type Stack = StackElement[]; export declare type StackFunction = () => Stack; -export { embed, p2ms, p2pk, p2pkh, p2sh, p2wpkh, p2wsh }; +export { embed, p2ms, p2pk, p2pkh, p2sh, p2tr, p2wpkh, p2wsh }; diff --git a/types/payments/p2tr.d.ts b/types/payments/p2tr.d.ts new file mode 100644 index 000000000..350ed0ffc --- /dev/null +++ b/types/payments/p2tr.d.ts @@ -0,0 +1,2 @@ +import { Payment, PaymentOpts } from './index'; +export declare function p2tr(a: Payment, opts?: PaymentOpts): Payment; diff --git a/types/taproot.d.ts b/types/taproot.d.ts new file mode 100644 index 000000000..facbfe887 --- /dev/null +++ b/types/taproot.d.ts @@ -0,0 +1,43 @@ +/** + * Trims the leading 02/03 byte from an ECDSA pub key to get a 32 byte schnorr + * pub key with x-only coordinates. + * @param pubkey A 33 byte pubkey representing an EC point + * @returns a 32 byte x-only coordinate + */ +export declare function trimFirstByte(pubkey: Buffer): Buffer; +/** + * Aggregates a list of public keys into a single MuSig public key + * according to the MuSig paper. + * @param pubkeys The list of pub keys to aggregate + * @returns a 32 byte Buffer representing the aggregate key + */ +export declare function aggregateMuSigPubkeys(pubkeys: Buffer[]): Buffer; +/** + * Gets a tapleaf tagged hash from a script. + * @param script + * @returns + */ +export declare function hashTapLeaf(script: Buffer): Buffer; +/** + * Creates a lexicographically sorted tapbranch from two child taptree nodes + * and returns its tagged hash. + * @param child1 + * @param child2 + * @returns the tagged tapbranch hash + */ +export declare function hashTapBranch(child1: Buffer, child2: Buffer): Buffer; +/** + * Tweaks an internal pubkey using the tagged hash of a taptree root. + * @param pubkey the internal pubkey to tweak + * @param tapTreeRoot the taptree root tagged hash + * @returns the tweaked pubkey + */ +export declare function tapTweakPubkey(pubkey: Buffer, tapTreeRoot?: Buffer): Buffer; +/** + * Gets the root hash of a taptree using a weighted Huffman construction from a + * list of scripts and corresponding weights, + * @param scripts + * @param weights + * @returns the tagged hash of the taptree root + */ +export declare function getHuffmanTaptreeRoot(scripts: Buffer[], weights?: number[]): Buffer;