diff --git a/package-lock.json b/package-lock.json index 4c9b08b..83431f5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@bitcoinerlab/descriptors", - "version": "2.3.4", + "version": "2.3.5", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@bitcoinerlab/descriptors", - "version": "2.3.4", + "version": "2.3.5", "license": "MIT", "dependencies": { "@bitcoinerlab/miniscript": "^1.4.3", diff --git a/package.json b/package.json index 5dbb660..dcd5111 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "@bitcoinerlab/descriptors", "description": "This library parses and creates Bitcoin Miniscript Descriptors and generates Partially Signed Bitcoin Transactions (PSBTs). It provides PSBT finalizers and signers for single-signature, BIP32 and Hardware Wallets.", "homepage": "https://github.com/bitcoinerlab/descriptors", - "version": "2.3.4", + "version": "2.3.5", "author": "Jose-Luis Landabaso", "license": "MIT", "repository": { @@ -36,13 +36,13 @@ "build": "npm run build:src && npm run build:test", "lint": "./node_modules/@bitcoinerlab/configs/scripts/lint.sh", "ensureTester": "./node_modules/@bitcoinerlab/configs/scripts/ensureTester.sh", - "test:integration:soft": "npm run ensureTester && node test/integration/standardOutputs.js && echo \"\\n\\n\" && node test/integration/miniscript.js", + "test:integration:soft": "npm run ensureTester && node test/integration/standardOutputs.js && echo \"\n\n\" && node test/integration/miniscript.js && echo \"\n\n\" && node test/integration/sortedmulti.js", "test:integration:ledger": "npm run ensureTester && node test/integration/ledger.js", - "test:integration:deprecated": "npm run ensureTester && node test/integration/standardOutputs-deprecated.js && echo \"\\n\\n\" && node test/integration/miniscript-deprecated.js && echo \"\\n\\n\" && node test/integration/ledger-deprecated.js", + "test:integration:deprecated": "npm run ensureTester && node test/integration/standardOutputs-deprecated.js && echo \"\n\n\" && node test/integration/miniscript-deprecated.js && echo \"\n\n\" && node test/integration/ledger-deprecated.js", "test:unit": "jest", "test": "npm run lint && npm run build && npm run test:unit && npm run test:integration:soft", "testledger": "npm run lint && npm run build && npm run test:integration:ledger", - "prepublishOnly": "npm run test && echo \"\\n\\n\" && npm run test:integration:deprecated && npm run test:integration:ledger" + "prepublishOnly": "npm run test && echo \"\n\n\" && npm run test:integration:deprecated && npm run test:integration:ledger" }, "files": [ "dist" diff --git a/src/descriptors.ts b/src/descriptors.ts index 13fbabf..70d8f31 100644 --- a/src/descriptors.ts +++ b/src/descriptors.ts @@ -140,6 +140,32 @@ function evaluate({ return evaluatedDescriptor; } +// Helper: parse sortedmulti(M, k1, k2,...) +function parseSortedMulti(inner: string) { + // inner: "2,key1,key2,key3" + + const parts = inner.split(',').map(p => p.trim()); + if (parts.length < 2) + throw new Error( + `sortedmulti(): must contain M and at least one key: ${inner}` + ); + + const m = Number(parts[0]); + if (!Number.isInteger(m) || m < 1 || m > 20) + throw new Error(`sortedmulti(): invalid M=${parts[0]}`); + + const keyExpressions = parts.slice(1); + if (keyExpressions.length < m) + throw new Error(`sortedmulti(): M cannot exceed number of keys: ${inner}`); + + if (keyExpressions.length > 20) + throw new Error( + `sortedmulti(): descriptors support up to 20 keys (per BIP 380/383).` + ); + + return { m, keyExpressions }; +} + /** * Constructs the necessary functions and classes for working with descriptors * using an external elliptic curve (ecc) library. @@ -451,6 +477,125 @@ export function DescriptorsFactory(ecc: TinySecp256k1Interface) { payment = p2wpkh({ pubkey, network }); } } + // sortedmulti script expressions + // sh(sortedmulti()) + else if (canonicalExpression.match(RE.reShSortedMultiAnchored)) { + isSegwit = false; + isTaproot = false; + + const inner = canonicalExpression.match(RE.reShSortedMultiAnchored)?.[1]; + if (!inner) + throw new Error(`Error extracting sortedmulti() in ${descriptor}`); + + const { m, keyExpressions } = parseSortedMulti(inner); + + const pKEs = keyExpressions.map(k => + parseKeyExpression({ keyExpression: k, network, isSegwit: false }) + ); + + const map: ExpansionMap = {}; + pKEs.forEach((pke, i) => (map['@' + i] = pke)); + expansionMap = map; + + expandedExpression = + 'sh(sortedmulti(' + + [m, ...Object.keys(expansionMap).map(k => k)].join(',') + + '))'; + + if (!isCanonicalRanged) { + const pubkeys = pKEs.map(p => { + if (!p.pubkey) throw new Error(`Error: key has no pubkey`); + return p.pubkey; + }); + pubkeys.sort((a, b) => a.compare(b)); + + const redeem = payments.p2ms({ m, pubkeys, network }); + redeemScript = redeem.output; + if (!redeemScript) throw new Error(`Error creating redeemScript`); + + payment = payments.p2sh({ redeem, network }); + } + } + // wsh(sortedmulti()) + else if (canonicalExpression.match(RE.reWshSortedMultiAnchored)) { + isSegwit = true; + isTaproot = false; + + const inner = canonicalExpression.match(RE.reWshSortedMultiAnchored)?.[1]; + if (!inner) + throw new Error(`Error extracting sortedmulti() in ${descriptor}`); + + const { m, keyExpressions } = parseSortedMulti(inner); + + const pKEs = keyExpressions.map(k => + parseKeyExpression({ keyExpression: k, network, isSegwit: true }) + ); + + const map: ExpansionMap = {}; + pKEs.forEach((pke, i) => (map['@' + i] = pke)); + expansionMap = map; + + expandedExpression = + 'wsh(sortedmulti(' + + [m, ...Object.keys(expansionMap).map(k => k)].join(',') + + '))'; + + if (!isCanonicalRanged) { + const pubkeys = pKEs.map(p => { + if (!p.pubkey) throw new Error(`Error: key has no pubkey`); + return p.pubkey; + }); + pubkeys.sort((a, b) => a.compare(b)); + + const redeem = payments.p2ms({ m, pubkeys, network }); + witnessScript = redeem.output; + if (!witnessScript) throw new Error(`Error computing witnessScript`); + + payment = payments.p2wsh({ redeem, network }); + } + } + // sh(wsh(sortedmulti())) + else if (canonicalExpression.match(RE.reShWshSortedMultiAnchored)) { + isSegwit = true; + isTaproot = false; + + const inner = canonicalExpression.match( + RE.reShWshSortedMultiAnchored + )?.[1]; + if (!inner) + throw new Error(`Error extracting sortedmulti() in ${descriptor}`); + + const { m, keyExpressions } = parseSortedMulti(inner); + + const pKEs = keyExpressions.map(k => + parseKeyExpression({ keyExpression: k, network, isSegwit: true }) + ); + + const map: ExpansionMap = {}; + pKEs.forEach((pke, i) => (map['@' + i] = pke)); + expansionMap = map; + + expandedExpression = + 'sh(wsh(sortedmulti(' + + [m, ...Object.keys(expansionMap).map(k => k)].join(',') + + ')))'; + + if (!isCanonicalRanged) { + const pubkeys = pKEs.map(p => { + if (!p.pubkey) throw new Error(`Error: key has no pubkey`); + return p.pubkey; + }); + pubkeys.sort((a, b) => a.compare(b)); + + const redeem = payments.p2ms({ m, pubkeys, network }); + const wsh = payments.p2wsh({ redeem, network }); + + witnessScript = redeem.output; + redeemScript = wsh.output; + + payment = payments.p2sh({ redeem: wsh, network }); + } + } //sh(wsh(miniscript)) else if (canonicalExpression.match(RE.reShWshMiniscriptAnchored)) { isSegwit = true; diff --git a/src/re.ts b/src/re.ts index 00ca672..092a37f 100644 --- a/src/re.ts +++ b/src/re.ts @@ -63,6 +63,9 @@ const reWpkh = String.raw`wpkh\(${reSegwitKeyExp}\)`; const reShWpkh = String.raw`sh\(wpkh\(${reSegwitKeyExp}\)\)`; const reTrSingleKey = String.raw`tr\(${reTaprootKeyExp}\)`; // TODO: tr(KEY,TREE) not yet supported. TrSingleKey used for tr(KEY) +export const reNonSegwitSortedMulti = String.raw`sortedmulti\(((?:1|2|3|4|5|6|7|8|9|10|11|12|13|14|15|16|17|18|19|20)(?:,${reNonSegwitKeyExp})+)\)`; +export const reSegwitSortedMulti = String.raw`sortedmulti\(((?:1|2|3|4|5|6|7|8|9|10|11|12|13|14|15|16|17|18|19|20)(?:,${reSegwitKeyExp})+)\)`; + const reMiniscript = String.raw`(.*?)`; //Matches anything. We assert later in the code that miniscripts are valid and sane. //RegExp makers: @@ -84,6 +87,16 @@ export const reTrSingleKeyAnchored = anchorStartAndEnd( composeChecksum(reTrSingleKey) ); +export const reShSortedMultiAnchored = anchorStartAndEnd( + composeChecksum(makeReSh(reNonSegwitSortedMulti)) +); +export const reWshSortedMultiAnchored = anchorStartAndEnd( + composeChecksum(makeReWsh(reSegwitSortedMulti)) +); +export const reShWshSortedMultiAnchored = anchorStartAndEnd( + composeChecksum(makeReShWsh(reSegwitSortedMulti)) +); + export const reShMiniscriptAnchored = anchorStartAndEnd( composeChecksum(makeReSh(reMiniscript)) ); diff --git a/test/fixtures/custom.ts b/test/fixtures/custom.ts index 3439e04..d098d73 100644 --- a/test/fixtures/custom.ts +++ b/test/fixtures/custom.ts @@ -571,6 +571,58 @@ export const fixtures = { } } } + }, + { + note: 'Native SegWit (P2WSH) sortedmulti descriptor with 2 keys', + descriptor: + 'wsh(sortedmulti(2,03a34b99f22c790c4e36b2b3c2c35a36db06226e41c692fc82b8b56ac1c540c5bd,0260b2003c386519fc9eadf2b5cf124dd8eea4c4e68d5e154050a9346ea98ce600))', + checksumRequired: false, + script: + '0020a18e1931caa82844ddb8294107de1b3e15f1c603983df8d9b6caa0ef6419c5d2', + address: 'bc1q5x8pjvw24q5yfhdc99qs0hsm8c2lr3srnq7l3kdke2sw7eqechfq62zxqh', + expansion: { + expandedExpression: 'wsh(sortedmulti(2,@0,@1))', + expansionMap: { + '@0': { + keyExpression: + '03a34b99f22c790c4e36b2b3c2c35a36db06226e41c692fc82b8b56ac1c540c5bd' + }, + '@1': { + keyExpression: + '0260b2003c386519fc9eadf2b5cf124dd8eea4c4e68d5e154050a9346ea98ce600' + } + } + } + }, + { + note: 'Nested SegWit (P2SH-P2WSH) sortedmulti descriptor with 2 of 3', + descriptor: + "sh(wsh(sortedmulti(2,[d34db33f/44'/0'/0]xpub6ERApfZwUNrhLCkDtcHTcxd75RbzS1ed54G1LkBUHQVHQKqhMkhgbmJbZRkrgZw4koxb5JaHWkY4ALHY2grBGRjaDMzQLcgJvLJuZZvRcEL/1/0/0,[00000000/44'/0'/0]xpub68NZiKmJWnxxS6aaHmn81bvJeTESw724CRDs6HbuccFQN9Ku14VQrADWgqbhhTHBaohPX4CjNLf9fq9MYo6oDaPPLPxSb7gwQN3ih19Zm4Y/0/0/0,[11111111/44'/0'/0]xpub661MyMwAqRbcFW31YEwpkMuc5THy2PSt5bDMsktWQcFF8syAmRUapSCGu8ED9W6oDMSgv6Zz8idoc4a6mr8BDzTJY47LJhkJ8UB7WEGuduB/0/0/0)))", + checksumRequired: false, + address: '3FZBUGnutPpKWrhGSRXwcMCv7M2ekUva5c', + expansion: { + expandedExpression: 'sh(wsh(sortedmulti(2,@0,@1,@2)))', + expansionMap: { + '@0': { + keyExpression: + "[d34db33f/44'/0'/0]xpub6ERApfZwUNrhLCkDtcHTcxd75RbzS1ed54G1LkBUHQVHQKqhMkhgbmJbZRkrgZw4koxb5JaHWkY4ALHY2grBGRjaDMzQLcgJvLJuZZvRcEL/1/0/0", + originPath: "/44'/0'/0", + path: "m/44'/0'/0/1/0/0" + }, + '@1': { + keyExpression: + "[00000000/44'/0'/0]xpub68NZiKmJWnxxS6aaHmn81bvJeTESw724CRDs6HbuccFQN9Ku14VQrADWgqbhhTHBaohPX4CjNLf9fq9MYo6oDaPPLPxSb7gwQN3ih19Zm4Y/0/0/0", + originPath: "/44'/0'/0", + path: "m/44'/0'/0/0/0/0" + }, + '@2': { + keyExpression: + "[11111111/44'/0'/0]xpub661MyMwAqRbcFW31YEwpkMuc5THy2PSt5bDMsktWQcFF8syAmRUapSCGu8ED9W6oDMSgv6Zz8idoc4a6mr8BDzTJY47LJhkJ8UB7WEGuduB/0/0/0", + originPath: "/44'/0'/0", + path: "m/44'/0'/0/0/0/0" + } + } + } } ], invalid: [ diff --git a/test/integration/sortedmulti.ts b/test/integration/sortedmulti.ts new file mode 100644 index 0000000..fa4a7bd --- /dev/null +++ b/test/integration/sortedmulti.ts @@ -0,0 +1,227 @@ +// Copyright (c) 2025 Jose-Luis Landabaso - https://bitcoinerlab.com +// Distributed under the MIT software license + +//npm run test:integration:soft + +console.log('Integration test: sortedmulti descriptors'); + +import { networks, Psbt } from 'bitcoinjs-lib'; +import { mnemonicToSeedSync } from 'bip39'; +import { RegtestUtils } from 'regtest-client'; +import * as ecc from '@bitcoinerlab/secp256k1'; + +import { DescriptorsFactory, keyExpressionBIP32, signers } from '../../dist/'; + +const regtestUtils = new RegtestUtils(); +const NETWORK = networks.regtest; + +const INITIAL_VALUE = 2e4; +const FINAL_VALUE = INITIAL_VALUE - 1000; +const FINAL_ADDRESS = regtestUtils.RANDOM_ADDRESS; + +// BIP32 setup ------------------------------------------------- +const SOFT_MNEMONIC = + 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about'; + +const seed = mnemonicToSeedSync(SOFT_MNEMONIC); +const { Output, BIP32, ECPair } = DescriptorsFactory(ecc); +const masterNode = BIP32.fromSeed(seed, NETWORK); + +// Helpers ----------------------------------------------------- +const { signBIP32, signECPair } = signers; + +// Create ECPairs for multisigs (will be reused everywhere) +const manyKeys = Array.from({ length: 25 }, () => ECPair.makeRandom()); + +// Make hex helper +const hex = (pk: Buffer) => pk.toString('hex'); + +// ----------------------------------------- +// Helper: build sortedmulti descriptor +// ----------------------------------------- +function makeSortedMulti(M: number, pubkeys: string[]) { + return `sortedmulti(${M},${pubkeys.join(',')})`; +} + +// ----------------------------------------- +// Wrapper for sh(), wsh(), sh(wsh()) +// ----------------------------------------- +function wrapSH(inner: string) { + return `sh(${inner})`; +} +function wrapWSH(inner: string) { + return `wsh(${inner})`; +} +function wrapSHWSH(inner: string) { + return `sh(wsh(${inner}))`; +} + +function parseSortedmultiParams(descriptor: string): { + m: number; + keys: string[]; +} { + const match = descriptor.match(/sortedmulti\((\d+),(.*)\)/); + if (!match) throw new Error(`Not a sortedmulti: ${descriptor}`); + const m = Number(match[1]); + const rawKeys = match[2]!.split(',').map(k => k.trim()); + return { m, keys: rawKeys }; +} + +// ----------------------------------------- +// Run a full regtest cycle: +// fund → build PSBT → sign → finalize → broadcast +// ----------------------------------------- +async function runIntegration(descriptor: string) { + console.log(`\nTesting descriptor: ${descriptor}`); + + let signed = 0; + const { m } = parseSortedmultiParams(descriptor); + + const output = new Output({ descriptor, network: NETWORK }); + + // FUND + const { txId, vout } = await regtestUtils.faucetComplex( + output.getScriptPubKey(), + INITIAL_VALUE + ); + + const { txHex } = await regtestUtils.fetch(txId); + + const psbt = new Psbt(); + + const finalizeInput = output.updatePsbtAsInput({ + psbt, + vout, + txHex + }); + + // Add final output + new Output({ + descriptor: `addr(${FINAL_ADDRESS})`, + network: NETWORK + }).updatePsbtAsOutput({ psbt, value: FINAL_VALUE }); + + // which pubkeys: + const expansion = output.expand(); + const required = Object.values(expansion.expansionMap ?? {}) + .map(e => e.pubkey?.toString('hex')) + .filter(Boolean) as string[]; + + // Sign with BIP32 (signs all pubkeys BIP32 controlled by masterNode) + signBIP32({ psbt, masterNode }); + signed++; + + // Sign with ECPair ONLY if it matches one of the required pubkeys + for (const k of manyKeys) { + if (required.includes(k.publicKey.toString('hex')) && signed < m) { + signECPair({ psbt, ecpair: k }); + signed++; + } + } + + // Finalize + finalizeInput({ psbt }); + + const tx = psbt.extractTransaction(); + + // Broadcast + await regtestUtils.broadcast(tx.toHex()); + + // Verify + await regtestUtils.verify({ + txId: tx.getId(), + address: FINAL_ADDRESS, + vout: 0, + value: FINAL_VALUE + }); + + console.log(`OK → ${descriptor}`); +} + +// ---------------------------------------------------------- +// NEGATIVE TESTS: Check sortedmulti errors +// ---------------------------------------------------------- +function expectError(label: string, fn: () => unknown): void { + try { + fn(); + console.error(`❌ Expected error not thrown: ${label}`); + process.exit(1); + } catch (err: unknown) { + const msg = err instanceof Error ? err.message : String(err); + console.log(`✓ Error OK (${label}): ${msg.slice(0, 120)}…`); + } +} + +(async () => { + // ---------------------------------------------------------- + // POSITIVE TESTS (real regtest, full integration) + // ---------------------------------------------------------- + + // Usamos siempre las mismas claves que conocemos: + const keyB = manyKeys[0]; + const keyC = manyKeys[1]; + + const pubA = keyExpressionBIP32({ + masterNode, + originPath: "/86'/1'/0'", + change: 0, + index: 0 + }); + const pubB = hex(keyB!.publicKey); + const pubC = hex(keyC!.publicKey); + + // --- Small 2-key multisigs (2-of-2) + const smallSorted = makeSortedMulti(2, [pubA, pubB]); + + await runIntegration(wrapSH(smallSorted)); + await runIntegration(wrapWSH(smallSorted)); + await runIntegration(wrapSHWSH(smallSorted)); + + // --- Medium multisig: 2-of-3 + const small3 = makeSortedMulti(2, [pubA, pubB, pubC]); + + await runIntegration(wrapSH(small3)); + await runIntegration(wrapWSH(small3)); + await runIntegration(wrapSHWSH(small3)); + + // ---------------------------------------------------------- + // LARGE multisigs + // ---------------------------------------------------------- + // sortedmulti currently limited to n=16 until this is merged: + // https://github.com/bitcoinjs/bitcoinjs-lib/pull/2297 + const manyPub = [ + pubA, // BIP32 key FIRST + ...manyKeys.slice(0, 15).map(k => hex(k.publicKey)) // 16 ECPairs + ]; + + const many = makeSortedMulti(2, manyPub); + + // Should work (except if P2WSH size > 3600 bytes) + await runIntegration(wrapWSH(many)); + + expectError( + 'SH > 520 bytes', + () => new Output({ descriptor: wrapSH(many), network: NETWORK }) + ); + + // ---------------------------------------------------------- + // NEGATIVE TESTS: M > N + // ---------------------------------------------------------- + expectError('M > N', () => { + const bad = makeSortedMulti(3, [pubA, pubB]); // M=3 N=2 + // Debe fallar al intentar parsear/expandir: + new Output({ descriptor: wrapWSH(bad), network: NETWORK }); + }); + + // ---------------------------------------------------------- + // NEGATIVE TESTS: >20 keys must fail validation + // ---------------------------------------------------------- + const manyPub21 = manyKeys.slice(0, 21).map(k => hex(k.publicKey)); + + expectError('sortedmulti with >20 keys', () => { + const bad = makeSortedMulti(2, manyPub21); + new Output({ descriptor: wrapWSH(bad), network: NETWORK }); + }); + + console.log('\nALL sortedmulti integration tests: OK\n'); +})(); diff --git a/test/integration/standardOutputs.ts b/test/integration/standardOutputs.ts index 1b4c41f..c0469e5 100644 --- a/test/integration/standardOutputs.ts +++ b/test/integration/standardOutputs.ts @@ -1,7 +1,7 @@ // Copyright (c) 2023 Jose-Luis Landabaso - https://bitcoinerlab.com // Distributed under the MIT software license -//npm run test:integration +//npm run test:integration:soft console.log('Standard output integration tests'); import { networks, Psbt } from 'bitcoinjs-lib'; diff --git a/test/tools/generateBitcoinCoreFixtures.js b/test/tools/generateBitcoinCoreFixtures.js index 7447746..dc4383c 100644 --- a/test/tools/generateBitcoinCoreFixtures.js +++ b/test/tools/generateBitcoinCoreFixtures.js @@ -91,7 +91,7 @@ const isSupported = parsed => { if (expression.match(/tr\(.*,.*\)/)) supported = false; // tr(KEY) supported. Disable when 2 param (contains a comma) if (expression.match(/^multi\(/)) supported = false; //Top-level multi not supported; It must be within sh or wsh if (expression.match(/combo\(/)) supported = false; - if (expression.match(/sortedmulti\(/)) supported = false; + if (expression.match(/^sortedmulti\(/)) supported = false; //Top-level sortedmulti not supported; It must be within sh or wsh if (expression.match(/raw\(/)) supported = false; }); return supported; diff --git a/tsconfig.json b/tsconfig.json index 8cec887..6da0cb6 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,4 +1,3 @@ -{ { "compilerOptions": { "resolveJsonModule": true,