diff --git a/src/psbt/bip371.d.ts b/src/psbt/bip371.d.ts index fae6e756b..f90a82cd7 100644 --- a/src/psbt/bip371.d.ts +++ b/src/psbt/bip371.d.ts @@ -1,5 +1,5 @@ /// -import { Taptree } from '../types'; +import { Taptree, HuffmanTapTreeNode } from '../types'; import { PsbtInput, PsbtOutput, TapLeaf } from 'bip174/src/lib/interfaces'; export declare const toXOnly: (pubKey: Buffer) => Buffer; /** @@ -38,4 +38,10 @@ export declare function tapTreeToList(tree: Taptree): TapLeaf[]; * @returns the corresponding taptree, or throws an exception if the tree cannot be reconstructed */ export declare function tapTreeFromList(leaves?: TapLeaf[]): Taptree; +/** + * Construct a Taptree where the leaves with the highest likelihood of use are closer to the root. + * @param nodes A list of nodes where each element contains a weight (likelihood of use) and + * a node which could be a Tapleaf or a branch in a Taptree + */ +export declare function createTapTreeUsingHuffmanConstructor(nodes: HuffmanTapTreeNode[]): Taptree; export declare function checkTaprootInputForSigs(input: PsbtInput, action: string): boolean; diff --git a/src/psbt/bip371.js b/src/psbt/bip371.js index 61196ead2..332019a4f 100644 --- a/src/psbt/bip371.js +++ b/src/psbt/bip371.js @@ -1,6 +1,7 @@ 'use strict'; Object.defineProperty(exports, '__esModule', { value: true }); exports.checkTaprootInputForSigs = + exports.createTapTreeUsingHuffmanConstructor = exports.tapTreeFromList = exports.tapTreeToList = exports.tweakInternalPubKey = @@ -18,6 +19,7 @@ const psbtutils_1 = require('./psbtutils'); const bip341_1 = require('../payments/bip341'); const payments_1 = require('../payments'); const psbtutils_2 = require('./psbtutils'); +const sortutils_1 = require('../sortutils'); const toXOnly = pubKey => (pubKey.length === 32 ? pubKey : pubKey.slice(1, 33)); exports.toXOnly = toXOnly; /** @@ -155,6 +157,35 @@ function tapTreeFromList(leaves = []) { return instertLeavesInTree(leaves); } exports.tapTreeFromList = tapTreeFromList; +/** + * Construct a Taptree where the leaves with the highest likelihood of use are closer to the root. + * @param nodes A list of nodes where each element contains a weight (likelihood of use) and + * a node which could be a Tapleaf or a branch in a Taptree + */ +function createTapTreeUsingHuffmanConstructor(nodes) { + if (nodes.length === 0) + throw new Error('Cannot create taptree from empty list.'); + const compare = (a, b) => a.weight - b.weight; + const sortedNodes = [...nodes].sort(compare); // Sort array in ascending order of weight + let newNode; + let nodeA, nodeB; + while (sortedNodes.length > 1) { + // Construct a new node from the two nodes with the least weight + nodeA = sortedNodes.shift(); // There will always be an element to pop + nodeB = sortedNodes.shift(); // because loop ends when length <= 1 + newNode = { + weight: nodeA.weight + nodeB.weight, + node: [nodeA.node, nodeB.node], + }; + // Place newNode back into array + (0, sortutils_1.insertIntoSortedArray)(sortedNodes, newNode, compare); + } + // Last node is the root node + const root = sortedNodes.shift(); + return root.node; +} +exports.createTapTreeUsingHuffmanConstructor = + createTapTreeUsingHuffmanConstructor; function checkTaprootInputForSigs(input, action) { const sigs = extractTaprootSigs(input); return sigs.some(sig => diff --git a/src/sortutils.d.ts b/src/sortutils.d.ts new file mode 100644 index 000000000..0a95c33ef --- /dev/null +++ b/src/sortutils.d.ts @@ -0,0 +1,9 @@ +/** + * Inserts an element into a sorted array. + * @template T + * @param {Array} array - The sorted array to insert into. + * @param {T} element - The element to insert. + * @param {(a: T, b: T) => number} compare - The comparison function used to sort the array. + * @returns {number} The index at which the element was inserted. + */ +export declare function insertIntoSortedArray(array: Array, element: T, compare: (a: T, b: T) => number): number; diff --git a/src/sortutils.js b/src/sortutils.js new file mode 100644 index 000000000..03f0c9013 --- /dev/null +++ b/src/sortutils.js @@ -0,0 +1,58 @@ +'use strict'; +Object.defineProperty(exports, '__esModule', { value: true }); +exports.insertIntoSortedArray = void 0; +/** + * Inserts an element into a sorted array. + * @template T + * @param {Array} array - The sorted array to insert into. + * @param {T} element - The element to insert. + * @param {(a: T, b: T) => number} compare - The comparison function used to sort the array. + * @returns {number} The index at which the element was inserted. + */ +function insertIntoSortedArray(array, element, compare) { + let high = array.length - 1; + let low = 0; + let mid; + let highElement, lowElement, midElement; + let compareHigh, compareLow, compareMid; + let targetIndex; + while (targetIndex === undefined) { + if (high < low) { + targetIndex = low; + continue; + } + mid = Math.floor((low + high) / 2); + highElement = array[high]; + lowElement = array[low]; + midElement = array[mid]; + compareHigh = compare(element, highElement); + compareLow = compare(element, lowElement); + compareMid = compare(element, midElement); + if (low === high) { + // Target index is either to the left or right of element at low + if (compareLow <= 0) targetIndex = low; + else targetIndex = low + 1; + continue; + } + if (compareHigh >= 0) { + // Target index is to the right of high + low = high; + continue; + } + if (compareLow <= 0) { + // Target index is to the left of low + high = low; + continue; + } + if (compareMid <= 0) { + // Target index is to the left of mid + high = mid; + continue; + } + // Target index is to the right of mid + low = mid + 1; + } + array.splice(targetIndex, 0, element); + return targetIndex; +} +exports.insertIntoSortedArray = insertIntoSortedArray; diff --git a/src/types.d.ts b/src/types.d.ts index 0b8a02f9c..416994770 100644 --- a/src/types.d.ts +++ b/src/types.d.ts @@ -26,6 +26,13 @@ export declare function isTapleaf(o: any): o is Tapleaf; * The tree has no balancing requirements. */ export type Taptree = [Taptree | Tapleaf, Taptree | Tapleaf] | Tapleaf; +export interface HuffmanTapTreeNode { + /** + * weight is the sum of the weight of all children under this node + */ + weight: number; + node: Taptree; +} export declare function isTaptree(scriptTree: any): scriptTree is Taptree; export interface TinySecp256k1Interface { isXOnlyPoint(p: Uint8Array): boolean; diff --git a/test/huffman.spec.ts b/test/huffman.spec.ts new file mode 100644 index 000000000..9855d9218 --- /dev/null +++ b/test/huffman.spec.ts @@ -0,0 +1,228 @@ +import * as assert from 'assert'; +import { describe, it } from 'mocha'; +import { HuffmanTapTreeNode, Taptree } from '../src/types'; +import { createTapTreeUsingHuffmanConstructor } from '../src/psbt/bip371'; + +describe('Taptree using Huffman Constructor', () => { + const scriptBuff = Buffer.from(''); + + it('test empty array', () => { + assert.throws(() => createTapTreeUsingHuffmanConstructor([]), { + message: 'Cannot create taptree from empty list.', + }); + }); + + it( + 'should return only one node for a single leaf', + testLeafDistances([{ weight: 1, node: { output: scriptBuff } }], [0]), + ); + + it( + 'should return a balanced tree for a list of scripts with equal weights', + testLeafDistances( + [ + { + weight: 1, + node: { + output: scriptBuff, + }, + }, + { + weight: 1, + node: { + output: scriptBuff, + }, + }, + { + weight: 1, + node: { + output: scriptBuff, + }, + }, + { + weight: 1, + node: { + output: scriptBuff, + }, + }, + ], + [2, 2, 2, 2], + ), + ); + + it( + 'should return an optimal binary tree for a list of scripts with weights [1, 2, 3, 4, 5]', + testLeafDistances( + [ + { + weight: 1, + node: { + output: scriptBuff, + }, + }, + { + weight: 2, + node: { + output: scriptBuff, + }, + }, + { + weight: 3, + node: { + output: scriptBuff, + }, + }, + { + weight: 4, + node: { + output: scriptBuff, + }, + }, + { + weight: 5, + node: { + output: scriptBuff, + }, + }, + ], + [3, 3, 2, 2, 2], + ), + ); + + it( + 'should return an optimal binary tree for a list of scripts with weights [1, 2, 3, 3]', + testLeafDistances( + [ + { + weight: 1, + node: { + output: scriptBuff, + }, + }, + { + weight: 2, + node: { + output: scriptBuff, + }, + }, + { + weight: 3, + node: { + output: scriptBuff, + }, + }, + { + weight: 3, + node: { + output: scriptBuff, + }, + }, + ], + [3, 3, 2, 1], + ), + ); + + it( + 'should return an optimal binary tree for a list of scripts with some negative weights: [1, 2, 3, -3]', + testLeafDistances( + [ + { + weight: 1, + node: { + output: scriptBuff, + }, + }, + { + weight: 2, + node: { + output: scriptBuff, + }, + }, + { + weight: 3, + node: { + output: scriptBuff, + }, + }, + { + weight: -3, + node: { + output: scriptBuff, + }, + }, + ], + [3, 2, 1, 3], + ), + ); + + it( + 'should return an optimal binary tree for a list of scripts with some weights specified as infinity', + testLeafDistances( + [ + { + weight: 1, + node: { + output: scriptBuff, + }, + }, + { + weight: Number.POSITIVE_INFINITY, + node: { + output: scriptBuff, + }, + }, + { + weight: 3, + node: { + output: scriptBuff, + }, + }, + { + weight: Number.NEGATIVE_INFINITY, + node: { + output: scriptBuff, + }, + }, + ], + [3, 1, 2, 3], + ), + ); +}); + +function testLeafDistances( + input: HuffmanTapTreeNode[], + expectedDistances: number[], +) { + return () => { + const tree = createTapTreeUsingHuffmanConstructor(input); + + if (!Array.isArray(tree)) { + // tree is just one node + assert.deepEqual([0], expectedDistances); + return; + } + + const leaves = input.map(value => value.node); + + const map = new Map(); // Map of leaf to actual distance + let currentDistance = 1; + let currentArray: Array = tree as any; + let nextArray: Array = []; + while (currentArray.length > 0) { + currentArray.forEach(value => { + if (Array.isArray(value)) { + nextArray = nextArray.concat(value); + return; + } + map.set(value, currentDistance); + }); + + currentDistance += 1; // New level + currentArray = nextArray; + nextArray = []; + } + + const actualDistances = leaves.map(value => map.get(value)); + assert.deepEqual(actualDistances, expectedDistances); + }; +} diff --git a/test/integration/taproot.spec.ts b/test/integration/taproot.spec.ts index 550a34608..6abdc8c0d 100644 --- a/test/integration/taproot.spec.ts +++ b/test/integration/taproot.spec.ts @@ -7,8 +7,13 @@ import { describe, it } from 'mocha'; import { PsbtInput, TapLeafScript } from 'bip174/src/lib/interfaces'; import { regtestUtils } from './_regtest'; import * as bitcoin from '../..'; -import { Taptree } from '../../src/types'; -import { toXOnly, tapTreeToList, tapTreeFromList } from '../../src/psbt/bip371'; +import { Taptree, HuffmanTapTreeNode } from '../../src/types'; +import { + toXOnly, + tapTreeToList, + tapTreeFromList, + createTapTreeUsingHuffmanConstructor, +} from '../../src/psbt/bip371'; import { witnessStackToScriptWitness } from '../../src/psbt/psbtutils'; import { TapLeaf } from 'bip174/src/lib/interfaces'; @@ -598,6 +603,139 @@ describe('bitcoinjs-lib (transaction with taproot)', () => { }); } }); + + it('can verify script-tree built using huffman constructor', async () => { + const internalKey = bip32.fromSeed(rng(64), regtest); + const leafKey = bip32.fromSeed(rng(64), regtest); + + const leafScriptAsm = `${toXOnly(leafKey.publicKey).toString( + 'hex', + )} OP_CHECKSIG`; + const leafScript = bitcoin.script.fromASM(leafScriptAsm); + + const nodes: HuffmanTapTreeNode[] = [ + { + weight: 5, + node: { + output: bitcoin.script.fromASM( + '50929b74c1a04954b78b4b6035e97a5e078a5a0f28ec96d547bfee9ace803ac0 OP_CHECKSIG', + ), + }, + }, + { + weight: 5, + node: { + output: bitcoin.script.fromASM( + '50929b74c1a04954b78b4b6035e97a5e078a5a0f28ec96d547bfee9ace803ac1 OP_CHECKSIG', + ), + }, + }, + { + weight: 1, + node: { + output: bitcoin.script.fromASM( + '2258b1c3160be0864a541854eec9164a572f094f7562628281a8073bb89173a7 OP_CHECKSIG', + ), + }, + }, + { + weight: 1, + node: { + output: bitcoin.script.fromASM( + '50929b74c1a04954b78b4b6035e97a5e078a5a0f28ec96d547bfee9ace803ac2 OP_CHECKSIG', + ), + }, + }, + { + weight: 4, + node: { + output: bitcoin.script.fromASM( + '50929b74c1a04954b78b4b6035e97a5e078a5a0f28ec96d547bfee9ace803ac3 OP_CHECKSIG', + ), + }, + }, + { + weight: 4, + node: { + output: bitcoin.script.fromASM( + '50929b74c1a04954b78b4b6035e97a5e078a5a0f28ec96d547bfee9ace803ac4 OP_CHECKSIG', + ), + }, + }, + { + weight: 7, + node: { + output: leafScript, + }, + }, + ]; + + const scriptTree: Taptree = createTapTreeUsingHuffmanConstructor(nodes); + + const redeem = { + output: leafScript, + redeemVersion: 192, + }; + + const { output, witness } = bitcoin.payments.p2tr({ + internalPubkey: toXOnly(internalKey.publicKey), + scriptTree, + redeem, + network: regtest, + }); + + // amount from faucet + const amount = 42e4; + // amount to send + const sendAmount = amount - 1e4; + // get faucet + const unspent = await regtestUtils.faucetComplex(output!, amount); + + const psbt = new bitcoin.Psbt({ network: regtest }); + psbt.addInput({ + hash: unspent.txId, + index: 0, + witnessUtxo: { value: amount, script: output! }, + }); + psbt.updateInput(0, { + tapLeafScript: [ + { + leafVersion: redeem.redeemVersion, + script: redeem.output, + controlBlock: witness![witness!.length - 1], + }, + ], + }); + + const sendInternalKey = bip32.fromSeed(rng(64), regtest); + const sendPubKey = toXOnly(sendInternalKey.publicKey); + const { address: sendAddress } = bitcoin.payments.p2tr({ + internalPubkey: sendPubKey, + scriptTree, + network: regtest, + }); + + psbt.addOutput({ + value: sendAmount, + address: sendAddress!, + tapInternalKey: sendPubKey, + tapTree: { leaves: tapTreeToList(scriptTree) }, + }); + + psbt.signInput(0, leafKey); + psbt.finalizeInput(0); + const tx = psbt.extractTransaction(); + const rawTx = tx.toBuffer(); + const hex = rawTx.toString('hex'); + + await regtestUtils.broadcast(hex); + await regtestUtils.verify({ + txId: tx.getId(), + address: sendAddress!, + vout: 0, + value: sendAmount, + }); + }); }); function buildLeafIndexFinalizer( diff --git a/test/sortutils.spec.ts b/test/sortutils.spec.ts new file mode 100644 index 000000000..403681680 --- /dev/null +++ b/test/sortutils.spec.ts @@ -0,0 +1,27 @@ +import * as assert from 'assert'; +import { describe, it } from 'mocha'; +import { insertIntoSortedArray } from '../src/sortutils'; + +describe('insertIntoSortedArray', () => { + it('insert 1 into []', testList([], 1, [1])); + + it('insert 2 into [1]', testList([1], 2, [1, 2])); + + it('insert 1 into [2]', testList([2], 1, [1, 2])); + + it('insert 3 into [1, 2]', testList([1, 2], 3, [1, 2, 3])); + + it('insert 2 into [1, 3]', testList([1, 3], 2, [1, 2, 3])); + + it('insert 1 into [2, 3]', testList([2, 3], 1, [1, 2, 3])); + + it('insert 2 into [1, 2, 3]', testList([1, 2, 3], 2, [1, 2, 2, 3])); +}); + +function testList(input: number[], insert: number, expected: number[]) { + return () => { + const compare = (a: number, b: number) => a - b; + insertIntoSortedArray(input, insert, compare); + assert.deepEqual(input, expected); + }; +} diff --git a/ts_src/psbt/bip371.ts b/ts_src/psbt/bip371.ts index 375d35cc6..115f0f455 100644 --- a/ts_src/psbt/bip371.ts +++ b/ts_src/psbt/bip371.ts @@ -1,4 +1,10 @@ -import { Taptree, Tapleaf, isTapleaf, isTaptree } from '../types'; +import { + Taptree, + Tapleaf, + isTapleaf, + isTaptree, + HuffmanTapTreeNode, +} from '../types'; import { PsbtInput, PsbtOutput, @@ -26,6 +32,7 @@ import { import { p2tr } from '../payments'; import { signatureBlocksAction } from './psbtutils'; +import { insertIntoSortedArray } from '../sortutils'; export const toXOnly = (pubKey: Buffer) => pubKey.length === 32 ? pubKey : pubKey.slice(1, 33); @@ -196,6 +203,40 @@ export function tapTreeFromList(leaves: TapLeaf[] = []): Taptree { return instertLeavesInTree(leaves); } +/** + * Construct a Taptree where the leaves with the highest likelihood of use are closer to the root. + * @param nodes A list of nodes where each element contains a weight (likelihood of use) and + * a node which could be a Tapleaf or a branch in a Taptree + */ +export function createTapTreeUsingHuffmanConstructor( + nodes: HuffmanTapTreeNode[], +): Taptree { + if (nodes.length === 0) + throw new Error('Cannot create taptree from empty list.'); + + const compare = (a: HuffmanTapTreeNode, b: HuffmanTapTreeNode) => + a.weight - b.weight; + const sortedNodes = [...nodes].sort(compare); // Sort array in ascending order of weight + + let newNode: HuffmanTapTreeNode; + let nodeA: HuffmanTapTreeNode, nodeB: HuffmanTapTreeNode; + while (sortedNodes.length > 1) { + // Construct a new node from the two nodes with the least weight + nodeA = sortedNodes.shift()!; // There will always be an element to pop + nodeB = sortedNodes.shift()!; // because loop ends when length <= 1 + newNode = { + weight: nodeA.weight + nodeB.weight, + node: [nodeA.node, nodeB.node], + }; + // Place newNode back into array + insertIntoSortedArray(sortedNodes, newNode, compare); + } + + // Last node is the root node + const root = sortedNodes.shift()!; + return root.node; +} + export function checkTaprootInputForSigs( input: PsbtInput, action: string, diff --git a/ts_src/sortutils.ts b/ts_src/sortutils.ts new file mode 100644 index 000000000..d7b5a0c34 --- /dev/null +++ b/ts_src/sortutils.ts @@ -0,0 +1,66 @@ +/** + * Inserts an element into a sorted array. + * @template T + * @param {Array} array - The sorted array to insert into. + * @param {T} element - The element to insert. + * @param {(a: T, b: T) => number} compare - The comparison function used to sort the array. + * @returns {number} The index at which the element was inserted. + */ +export function insertIntoSortedArray( + array: Array, + element: T, + compare: (a: T, b: T) => number, +) { + let high = array.length - 1; + let low = 0; + let mid; + let highElement, lowElement, midElement; + let compareHigh, compareLow, compareMid; + let targetIndex; + while (targetIndex === undefined) { + if (high < low) { + targetIndex = low; + continue; + } + + mid = Math.floor((low + high) / 2); + + highElement = array[high]; + lowElement = array[low]; + midElement = array[mid]; + + compareHigh = compare(element, highElement); + compareLow = compare(element, lowElement); + compareMid = compare(element, midElement); + + if (low === high) { + // Target index is either to the left or right of element at low + if (compareLow <= 0) targetIndex = low; + else targetIndex = low + 1; + continue; + } + + if (compareHigh >= 0) { + // Target index is to the right of high + low = high; + continue; + } + if (compareLow <= 0) { + // Target index is to the left of low + high = low; + continue; + } + + if (compareMid <= 0) { + // Target index is to the left of mid + high = mid; + continue; + } + + // Target index is to the right of mid + low = mid + 1; + } + + array.splice(targetIndex, 0, element); + return targetIndex; +} diff --git a/ts_src/types.ts b/ts_src/types.ts index 2457ec240..edd0a742c 100644 --- a/ts_src/types.ts +++ b/ts_src/types.ts @@ -93,6 +93,14 @@ export function isTapleaf(o: any): o is Tapleaf { */ export type Taptree = [Taptree | Tapleaf, Taptree | Tapleaf] | Tapleaf; +export interface HuffmanTapTreeNode { + /** + * weight is the sum of the weight of all children under this node + */ + weight: number; + node: Taptree; +} + export function isTaptree(scriptTree: any): scriptTree is Taptree { if (!Array(scriptTree)) return isTapleaf(scriptTree); if (scriptTree.length !== 2) return false;