From 21b2a44f5003af06a548726d220ed7d1ff8a52f4 Mon Sep 17 00:00:00 2001 From: jasonandjay <342690199@qq.com> Date: Sun, 15 Feb 2026 10:43:12 +0800 Subject: [PATCH 1/4] feat: add Pay-to-Merkle-Root (P2MR) payment type per BIP 360 Implement SegWit v2 P2MR output type that commits directly to a script tree Merkle root without an internal public key, supporting script-path spending only. This provides resistance to long-exposure quantum attacks by removing the key-path spend vector present in P2TR. - Add p2mr payment module (ts_src/payments/p2mr.ts) - Add rootHashFromP2MRPath to bip341 utilities for P2MR control blocks - Export p2mr from payments index - Update address encoding/decoding for SegWit v2 (bc1z prefix) - Extend PSBT bip371 to recognize P2MR inputs/outputs - Add isP2MR detection to psbtutils --- src/cjs/address.cjs | 8 + src/cjs/address.d.ts | 6 +- src/cjs/payments/bip341.cjs | 32 ++++ src/cjs/payments/bip341.d.ts | 18 +++ src/cjs/payments/index.cjs | 10 +- src/cjs/payments/index.d.ts | 4 +- src/cjs/payments/p2mr.cjs | 304 +++++++++++++++++++++++++++++++++++ src/cjs/payments/p2mr.d.ts | 15 ++ src/cjs/psbt/bip371.cjs | 41 +++-- src/cjs/psbt/bip371.d.ts | 10 +- src/cjs/psbt/psbtutils.cjs | 4 +- src/cjs/psbt/psbtutils.d.ts | 1 + src/esm/address.js | 8 + src/esm/payments/bip341.js | 32 +++- src/esm/payments/index.js | 3 +- src/esm/payments/p2mr.js | 256 +++++++++++++++++++++++++++++ src/esm/psbt/bip371.js | 32 +++- src/esm/psbt/psbtutils.js | 1 + ts_src/address.ts | 14 +- ts_src/payments/bip341.ts | 38 ++++- ts_src/payments/index.ts | 4 +- ts_src/payments/p2mr.ts | 294 +++++++++++++++++++++++++++++++++ ts_src/psbt/bip371.ts | 34 +++- ts_src/psbt/psbtutils.ts | 1 + 24 files changed, 1126 insertions(+), 44 deletions(-) create mode 100644 src/cjs/payments/p2mr.cjs create mode 100644 src/cjs/payments/p2mr.d.ts create mode 100644 src/esm/payments/p2mr.js create mode 100644 ts_src/payments/p2mr.ts diff --git a/src/cjs/address.cjs b/src/cjs/address.cjs index 78f42ef4cc..ddad065fc9 100644 --- a/src/cjs/address.cjs +++ b/src/cjs/address.cjs @@ -200,6 +200,9 @@ function fromOutputScript(output, network) { try { return payments.p2tr({ output, network }).address; } catch (e) {} + try { + return payments.p2mr({ output, network }).address; + } catch (e) {} try { return _toFutureSegwitAddress(output, network); } catch (e) {} @@ -239,6 +242,11 @@ function toOutputScript(address, network) { } else if (decodeBech32.version === 1) { if (decodeBech32.data.length === 32) return payments.p2tr({ pubkey: decodeBech32.data }).output; + } else if ( + decodeBech32.version === 2 && + decodeBech32.data.length === 32 + ) { + return payments.p2mr({ hash: decodeBech32.data }).output; } else if ( decodeBech32.version >= FUTURE_SEGWIT_MIN_VERSION && decodeBech32.version <= FUTURE_SEGWIT_MAX_VERSION && diff --git a/src/cjs/address.d.ts b/src/cjs/address.d.ts index 06229d7f86..67ef44f43a 100644 --- a/src/cjs/address.d.ts +++ b/src/cjs/address.d.ts @@ -17,11 +17,11 @@ export interface Base58CheckResult { } /** bech32 decode result */ export interface Bech32Result { - /** address version: 0x00 for P2WPKH、P2WSH, 0x01 for P2TR*/ + /** address version: 0x00 for P2WPKH/P2WSH, 0x01 for P2TR, 0x02 for P2MR */ version: number; - /** address prefix: bc for P2WPKH、P2WSH、P2TR */ + /** address prefix: bc for P2WPKH/P2WSH/P2TR/P2MR */ prefix: string; - /** address data:20 bytes for P2WPKH, 32 bytes for P2WSH、P2TR */ + /** address data:20 bytes for P2WPKH, 32 bytes for P2WSH/P2TR/P2MR */ data: Uint8Array; } /** diff --git a/src/cjs/payments/bip341.cjs b/src/cjs/payments/bip341.cjs index c2adb34584..32aa8c7a25 100644 --- a/src/cjs/payments/bip341.cjs +++ b/src/cjs/payments/bip341.cjs @@ -46,11 +46,13 @@ var __importStar = Object.defineProperty(exports, '__esModule', { value: true }); exports.MAX_TAPTREE_DEPTH = exports.LEAF_VERSION_TAPSCRIPT = void 0; exports.rootHashFromPath = rootHashFromPath; +exports.rootHashFromP2MRPath = rootHashFromP2MRPath; exports.toHashTree = toHashTree; exports.findScriptPath = findScriptPath; exports.tapleafHash = tapleafHash; exports.tapTweakHash = tapTweakHash; exports.tweakKey = tweakKey; +exports.tapBranchHash = tapBranchHash; const ecc_lib_js_1 = require('../ecc_lib.cjs'); const bcrypto = __importStar(require('../crypto.cjs')); const bufferutils_js_1 = require('../bufferutils.cjs'); @@ -83,6 +85,36 @@ function rootHashFromPath(controlBlock, leafHash) { } return kj; } +/** + * Calculates the root hash from a P2MR control block and leaf hash. + * Unlike P2TR, P2MR control blocks do not include an internal public key, + * so the Merkle path starts at byte offset 1 (after the control byte). + * @param controlBlock - The P2MR control block buffer (1 + 32*m bytes). + * @param leafHash - The leaf hash buffer. + * @returns The root hash buffer. + * @throws {TypeError} If the control block length is invalid. + */ +function rootHashFromP2MRPath(controlBlock, leafHash) { + if (controlBlock.length < 1) + throw new TypeError( + `The control-block length is too small. Got ${controlBlock.length}, expected min 1.`, + ); + if ((controlBlock.length - 1) % 32 !== 0) + throw new TypeError( + `The control-block length of ${controlBlock.length} is incorrect for P2MR!`, + ); + const m = (controlBlock.length - 1) / 32; + let kj = leafHash; + for (let j = 0; j < m; j++) { + const ej = controlBlock.slice(1 + 32 * j, 33 + 32 * j); + if (tools.compare(kj, ej) < 0) { + kj = tapBranchHash(kj, ej); + } else { + kj = tapBranchHash(ej, kj); + } + } + return kj; +} /** * Build a hash tree of merkle nodes from the scripts binary tree. * @param scriptTree - the tree of scripts to pairwise hash. diff --git a/src/cjs/payments/bip341.d.ts b/src/cjs/payments/bip341.d.ts index 12b388578b..51377867a9 100644 --- a/src/cjs/payments/bip341.d.ts +++ b/src/cjs/payments/bip341.d.ts @@ -28,6 +28,16 @@ export type HashTree = HashLeaf | HashBranch; * @throws {TypeError} If the control block length is less than 33. */ export declare function rootHashFromPath(controlBlock: Uint8Array, leafHash: Uint8Array): Uint8Array; +/** + * Calculates the root hash from a P2MR control block and leaf hash. + * Unlike P2TR, P2MR control blocks do not include an internal public key, + * so the Merkle path starts at byte offset 1 (after the control byte). + * @param controlBlock - The P2MR control block buffer (1 + 32*m bytes). + * @param leafHash - The leaf hash buffer. + * @returns The root hash buffer. + * @throws {TypeError} If the control block length is invalid. + */ +export declare function rootHashFromP2MRPath(controlBlock: Uint8Array, leafHash: Uint8Array): Uint8Array; /** * Build a hash tree of merkle nodes from the scripts binary tree. * @param scriptTree - the tree of scripts to pairwise hash. @@ -65,4 +75,12 @@ export declare function tapTweakHash(pubKey: Uint8Array, h: Uint8Array | undefin * @returns The tweaked public key or null if the input is invalid. */ export declare function tweakKey(pubKey: Uint8Array, h: Uint8Array | undefined): TweakedPublicKey | null; +/** + * Computes the TapBranch hash by concatenating two buffers and applying the 'TapBranch' tagged hash algorithm. + * + * @param a - The first buffer. + * @param b - The second buffer. + * @returns The TapBranch hash of the concatenated buffers. + */ +export declare function tapBranchHash(a: Uint8Array, b: Uint8Array): Uint8Array; export {}; diff --git a/src/cjs/payments/index.cjs b/src/cjs/payments/index.cjs index 2db18ed77c..e7ec315708 100644 --- a/src/cjs/payments/index.cjs +++ b/src/cjs/payments/index.cjs @@ -1,6 +1,7 @@ 'use strict'; Object.defineProperty(exports, '__esModule', { value: true }); -exports.p2tr = +exports.p2mr = + exports.p2tr = exports.p2wsh = exports.p2wpkh = exports.p2sh = @@ -65,5 +66,12 @@ Object.defineProperty(exports, 'p2tr', { return p2tr_js_1.p2tr; }, }); +const p2mr_js_1 = require('./p2mr.cjs'); +Object.defineProperty(exports, 'p2mr', { + enumerable: true, + get: function () { + return p2mr_js_1.p2mr; + }, +}); // TODO // witness commitment diff --git a/src/cjs/payments/index.d.ts b/src/cjs/payments/index.d.ts index 6be3c722d7..28a67edb5e 100644 --- a/src/cjs/payments/index.d.ts +++ b/src/cjs/payments/index.d.ts @@ -7,6 +7,7 @@ * - P2WPKH (Pay-to-Witness-PubKey-Hash) * - P2WSH (Pay-to-Witness-Script-Hash) * - P2TR (Taproot) + * - P2MR (Pay-to-Merkle-Root, BIP 360) * * The `Payment` interface defines the structure of a payment object used for constructing various * payment types, with fields for signatures, public keys, redeem scripts, and more. @@ -23,6 +24,7 @@ import { p2sh } from './p2sh.js'; import { p2wpkh } from './p2wpkh.js'; import { p2wsh } from './p2wsh.js'; import { p2tr } from './p2tr.js'; +import { p2mr } from './p2mr.js'; export interface Payment { name?: string; network?: Network; @@ -52,4 +54,4 @@ export interface PaymentOpts { export type StackElement = Uint8Array | number; export type Stack = StackElement[]; export type StackFunction = () => Stack; -export { embed, p2ms, p2pk, p2pkh, p2sh, p2wpkh, p2wsh, p2tr }; +export { embed, p2ms, p2pk, p2pkh, p2sh, p2wpkh, p2wsh, p2tr, p2mr }; diff --git a/src/cjs/payments/p2mr.cjs b/src/cjs/payments/p2mr.cjs new file mode 100644 index 0000000000..883801eea2 --- /dev/null +++ b/src/cjs/payments/p2mr.cjs @@ -0,0 +1,304 @@ +'use strict'; +var __createBinding = + (this && this.__createBinding) || + (Object.create + ? function (o, m, k, k2) { + if (k2 === undefined) k2 = k; + var desc = Object.getOwnPropertyDescriptor(m, k); + if ( + !desc || + ('get' in desc ? !m.__esModule : desc.writable || desc.configurable) + ) { + desc = { + enumerable: true, + get: function () { + return m[k]; + }, + }; + } + Object.defineProperty(o, k2, desc); + } + : function (o, m, k, k2) { + if (k2 === undefined) k2 = k; + o[k2] = m[k]; + }); +var __setModuleDefault = + (this && this.__setModuleDefault) || + (Object.create + ? function (o, v) { + Object.defineProperty(o, 'default', { enumerable: true, value: v }); + } + : function (o, v) { + o['default'] = v; + }); +var __importStar = + (this && this.__importStar) || + function (mod) { + if (mod && mod.__esModule) return mod; + var result = {}; + if (mod != null) + for (var k in mod) + if (k !== 'default' && Object.prototype.hasOwnProperty.call(mod, k)) + __createBinding(result, mod, k); + __setModuleDefault(result, mod); + return result; + }; +Object.defineProperty(exports, '__esModule', { value: true }); +exports.p2mr = p2mr; +const networks_js_1 = require('../networks.cjs'); +const bscript = __importStar(require('../script.cjs')); +const types_js_1 = require('../types.cjs'); +const bip341_js_1 = require('./bip341.cjs'); +const lazy = __importStar(require('./lazy.cjs')); +const bech32_1 = require('bech32'); +const address_js_1 = require('../address.cjs'); +const tools = __importStar(require('uint8array-tools')); +const v = __importStar(require('valibot')); +const OPS = bscript.OPS; +const P2MR_WITNESS_VERSION = 0x02; +const ANNEX_PREFIX = 0x50; +/** + * Creates a Pay-to-Merkle-Root (P2MR) payment object (BIP 360). + * + * P2MR is a SegWit version 2 output type that commits directly to the + * Merkle root of a script tree without an internal public key. + * It supports only script-path spending (no key-path spend), + * providing resistance to long exposure quantum attacks. + * + * @param a - The payment object containing the necessary data for P2MR. + * @param opts - Optional payment options. + * @returns The P2MR payment object. + * @throws {TypeError} If the provided data is invalid or insufficient. + */ +function p2mr(a, opts) { + if ( + !a.address && + !a.output && + !a.hash && + !a.scriptTree && + !(a.witness && a.witness.length > 1) + ) + throw new TypeError('Not enough data'); + opts = Object.assign({ validate: true }, opts || {}); + v.parse( + v.partial( + v.object({ + address: v.string(), + input: (0, types_js_1.NBufferSchemaFactory)(0), + network: v.object({}), + output: (0, types_js_1.NBufferSchemaFactory)(34), + hash: (0, types_js_1.NBufferSchemaFactory)(32), // merkle root of the script tree + witness: v.array(types_js_1.BufferSchema), + scriptTree: v.custom( + types_js_1.isTaptree, + 'Taptree is not of type isTaptree', + ), + redeem: v.partial( + v.object({ + output: types_js_1.BufferSchema, // tapleaf script + redeemVersion: v.number(), // tapleaf version + witness: v.array(types_js_1.BufferSchema), + }), + ), + redeemVersion: v.number(), + }), + ), + a, + ); + const _address = lazy.value(() => { + return (0, address_js_1.fromBech32)(a.address); + }); + // remove annex if present, ignored by taproot/P2MR + const _witness = lazy.value(() => { + if (!a.witness || !a.witness.length) return; + if ( + a.witness.length >= 2 && + a.witness[a.witness.length - 1][0] === ANNEX_PREFIX + ) { + return a.witness.slice(0, -1); + } + return a.witness.slice(); + }); + const _hashTree = lazy.value(() => { + if (a.scriptTree) return (0, bip341_js_1.toHashTree)(a.scriptTree); + if (a.hash) return { hash: a.hash }; + return; + }); + const network = a.network || networks_js_1.bitcoin; + const o = { name: 'p2mr', network }; + lazy.prop(o, 'address', () => { + if (!o.hash) return; + const words = bech32_1.bech32m.toWords(o.hash); + words.unshift(P2MR_WITNESS_VERSION); + return bech32_1.bech32m.encode(network.bech32, words); + }); + lazy.prop(o, 'hash', () => { + const hashTree = _hashTree(); + if (hashTree) return hashTree.hash; + if (a.output) return a.output.slice(2); + if (a.address) return _address().data; + const w = _witness(); + if (w && w.length > 1) { + const controlBlock = w[w.length - 1]; + const leafVersion = controlBlock[0] & types_js_1.TAPLEAF_VERSION_MASK; + const script = w[w.length - 2]; + const leafHash = (0, bip341_js_1.tapleafHash)({ + output: script, + version: leafVersion, + }); + return (0, bip341_js_1.rootHashFromP2MRPath)(controlBlock, leafHash); + } + return null; + }); + lazy.prop(o, 'output', () => { + if (!o.hash) return; + // P2MR scriptPubKey: OP_2 OP_PUSHBYTES_32 <32-byte merkle_root> + return bscript.compile([OPS.OP_2, o.hash]); + }); + lazy.prop(o, 'redeemVersion', () => { + if (a.redeemVersion) return a.redeemVersion; + if ( + a.redeem && + a.redeem.redeemVersion !== undefined && + a.redeem.redeemVersion !== null + ) { + return a.redeem.redeemVersion; + } + return bip341_js_1.LEAF_VERSION_TAPSCRIPT; + }); + lazy.prop(o, 'redeem', () => { + const witness = _witness(); // witness without annex + if (!witness || witness.length < 2) return; + return { + output: witness[witness.length - 2], + witness: witness.slice(0, -2), + redeemVersion: + witness[witness.length - 1][0] & types_js_1.TAPLEAF_VERSION_MASK, + }; + }); + lazy.prop(o, 'witness', () => { + if (a.witness) return a.witness; + const hashTree = _hashTree(); + if (hashTree && a.redeem && a.redeem.output) { + const leafHash = (0, bip341_js_1.tapleafHash)({ + output: a.redeem.output, + version: o.redeemVersion, + }); + const path = (0, bip341_js_1.findScriptPath)(hashTree, leafHash); + if (!path) return; + // P2MR control block: [control_byte] [32*m byte merkle_path] + // No internal pubkey (unlike P2TR). + // Parity bit is always 1 for P2MR. + const controlBlock = tools.concat( + [Uint8Array.from([o.redeemVersion | 1])].concat(path), + ); + return [a.redeem.output, controlBlock]; + } + }); + // extended validation + if (opts.validate) { + let hash = Uint8Array.from([]); + if (a.address) { + if (network && network.bech32 !== _address().prefix) + throw new TypeError('Invalid prefix or Network mismatch'); + if (_address().version !== P2MR_WITNESS_VERSION) + throw new TypeError('Invalid address version'); + if (_address().data.length !== 32) + throw new TypeError('Invalid address data'); + hash = _address().data; + } + if (a.hash) { + if (hash.length > 0 && tools.compare(hash, a.hash) !== 0) + throw new TypeError('Hash mismatch'); + else hash = a.hash; + } + if (a.output) { + if ( + a.output.length !== 34 || + a.output[0] !== OPS.OP_2 || + a.output[1] !== 0x20 + ) + throw new TypeError('Output is invalid'); + if (hash.length > 0 && tools.compare(hash, a.output.slice(2)) !== 0) + throw new TypeError('Hash mismatch'); + else hash = a.output.slice(2); + } + const hashTree = _hashTree(); + if (a.hash && hashTree) { + if (tools.compare(a.hash, hashTree.hash) !== 0) + throw new TypeError('Hash mismatch'); + } + if (a.redeem && a.redeem.output && hashTree) { + const leafHash = (0, bip341_js_1.tapleafHash)({ + output: a.redeem.output, + version: o.redeemVersion, + }); + if (!(0, bip341_js_1.findScriptPath)(hashTree, leafHash)) + throw new TypeError('Redeem script not in tree'); + } + const witness = _witness(); + // compare the provided redeem data with the one computed from witness + if (a.redeem && o.redeem) { + if (a.redeem.redeemVersion) { + if (a.redeem.redeemVersion !== o.redeem.redeemVersion) + throw new TypeError('Redeem.redeemVersion and witness mismatch'); + } + if (a.redeem.output) { + if (bscript.decompile(a.redeem.output).length === 0) + throw new TypeError('Redeem.output is invalid'); + // output redeem is constructed from the witness + if ( + o.redeem.output && + tools.compare(a.redeem.output, o.redeem.output) !== 0 + ) + throw new TypeError('Redeem.output and witness mismatch'); + } + if (a.redeem.witness) { + if ( + o.redeem.witness && + !(0, types_js_1.stacksEqual)(a.redeem.witness, o.redeem.witness) + ) + throw new TypeError('Redeem.witness and witness mismatch'); + } + } + if (witness && witness.length) { + // P2MR only supports script-path spending (no key-path) + if (witness.length < 2) { + throw new TypeError( + 'P2MR does not support key-path spending. Witness must have at least 2 elements.', + ); + } + // Script-path spending validation + const controlBlock = witness[witness.length - 1]; + if (controlBlock.length < 1) + throw new TypeError( + `The control-block length is too small. Got ${controlBlock.length}, expected min 1.`, + ); + if ((controlBlock.length - 1) % 32 !== 0) + throw new TypeError( + `The control-block length of ${controlBlock.length} is incorrect!`, + ); + const m = (controlBlock.length - 1) / 32; + if (m > 128) + throw new TypeError( + `The script path is too long. Got ${m}, expected max 128.`, + ); + // P2MR parity bit must always be 1 + if ((controlBlock[0] & 1) !== 1) + throw new TypeError('P2MR control block parity bit must be 1'); + const leafVersion = controlBlock[0] & types_js_1.TAPLEAF_VERSION_MASK; + const script = witness[witness.length - 2]; + const leafHash = (0, bip341_js_1.tapleafHash)({ + output: script, + version: leafVersion, + }); + const rootHash = (0, bip341_js_1.rootHashFromP2MRPath)( + controlBlock, + leafHash, + ); + if (hash.length && tools.compare(hash, rootHash) !== 0) + throw new TypeError('Hash mismatch for p2mr witness'); + } + } + return Object.assign(o, a); +} diff --git a/src/cjs/payments/p2mr.d.ts b/src/cjs/payments/p2mr.d.ts new file mode 100644 index 0000000000..c9a9ca1834 --- /dev/null +++ b/src/cjs/payments/p2mr.d.ts @@ -0,0 +1,15 @@ +import { Payment, PaymentOpts } from './index.js'; +/** + * Creates a Pay-to-Merkle-Root (P2MR) payment object (BIP 360). + * + * P2MR is a SegWit version 2 output type that commits directly to the + * Merkle root of a script tree without an internal public key. + * It supports only script-path spending (no key-path spend), + * providing resistance to long exposure quantum attacks. + * + * @param a - The payment object containing the necessary data for P2MR. + * @param opts - Optional payment options. + * @returns The P2MR payment object. + * @throws {TypeError} If the provided data is invalid or insufficient. + */ +export declare function p2mr(a: Payment, opts?: PaymentOpts): Payment; diff --git a/src/cjs/psbt/bip371.cjs b/src/cjs/psbt/bip371.cjs index 55edda1982..ae253ca3c9 100644 --- a/src/cjs/psbt/bip371.cjs +++ b/src/cjs/psbt/bip371.cjs @@ -109,9 +109,11 @@ function serializeTaprootSignature(sig, sighashType) { return tools.concat([sig, sighashTypeByte]); } /** - * Checks if a PSBT input is a taproot input. + * Checks if a PSBT input is a taproot or P2MR input. + * P2MR inputs use the same PSBT fields as taproot (tapLeafScript, tapScriptSig, etc.) + * but without tapInternalKey (no key-path spend). * @param input The PSBT input to check. - * @returns True if the input is a taproot input, false otherwise. + * @returns True if the input is a taproot or P2MR input, false otherwise. */ function isTaprootInput(input) { return ( @@ -122,15 +124,16 @@ function isTaprootInput(input) { (input.tapLeafScript && input.tapLeafScript.length) || (input.tapBip32Derivation && input.tapBip32Derivation.length) || (input.witnessUtxo && - (0, psbtutils_js_1.isP2TR)(input.witnessUtxo.script)) + ((0, psbtutils_js_1.isP2TR)(input.witnessUtxo.script) || + (0, psbtutils_js_1.isP2MR)(input.witnessUtxo.script))) ) ); } /** - * Checks if a PSBT output is a taproot output. + * Checks if a PSBT output is a taproot or P2MR output. * @param output The PSBT output to check. * @param script The script to check. Optional. - * @returns True if the output is a taproot output, false otherwise. + * @returns True if the output is a taproot or P2MR output, false otherwise. */ function isTaprootOutput(output, script) { return ( @@ -139,7 +142,9 @@ function isTaprootOutput(output, script) { output.tapInternalKey || output.tapTree || (output.tapBip32Derivation && output.tapBip32Derivation.length) || - (script && (0, psbtutils_js_1.isP2TR)(script)) + (script && + ((0, psbtutils_js_1.isP2TR)(script) || + (0, psbtutils_js_1.isP2MR)(script))) ) ); } @@ -445,6 +450,8 @@ function checkIfTapLeafInTree(inputData, newInputData, action) { } /** * Checks if a TapLeafScript is present in a Merkle tree. + * Supports both P2TR control blocks (with internal pubkey) and + * P2MR control blocks (without internal pubkey). * @param tapLeaf The TapLeafScript to check. * @param merkleRoot The Merkle root of the tree. If not provided, the function assumes the TapLeafScript is present. * @returns A boolean indicating whether the TapLeafScript is present in the tree. @@ -455,11 +462,23 @@ function isTapLeafInTree(tapLeaf, merkleRoot) { output: tapLeaf.script, version: tapLeaf.leafVersion, }); - const rootHash = (0, bip341_js_1.rootHashFromPath)( - tapLeaf.controlBlock, - leafHash, - ); - return tools.compare(rootHash, merkleRoot) === 0; + // Try P2TR format first (control block includes 32-byte internal pubkey) + try { + const rootHash = (0, bip341_js_1.rootHashFromPath)( + tapLeaf.controlBlock, + leafHash, + ); + if (tools.compare(rootHash, merkleRoot) === 0) return true; + } catch (e) {} + // Try P2MR format (control block has no internal pubkey, offset 1) + try { + const rootHash = (0, bip341_js_1.rootHashFromP2MRPath)( + tapLeaf.controlBlock, + leafHash, + ); + if (tools.compare(rootHash, merkleRoot) === 0) return true; + } catch (e) {} + return false; } /** * Sorts the signatures in the input's tapScriptSig array based on their position in the tapLeaf script. diff --git a/src/cjs/psbt/bip371.d.ts b/src/cjs/psbt/bip371.d.ts index 52e16c8546..0cd08b6559 100644 --- a/src/cjs/psbt/bip371.d.ts +++ b/src/cjs/psbt/bip371.d.ts @@ -26,16 +26,18 @@ export declare function tapScriptFinalizer(inputIndex: number, input: PsbtInput, */ export declare function serializeTaprootSignature(sig: Uint8Array, sighashType?: number): Uint8Array; /** - * Checks if a PSBT input is a taproot input. + * Checks if a PSBT input is a taproot or P2MR input. + * P2MR inputs use the same PSBT fields as taproot (tapLeafScript, tapScriptSig, etc.) + * but without tapInternalKey (no key-path spend). * @param input The PSBT input to check. - * @returns True if the input is a taproot input, false otherwise. + * @returns True if the input is a taproot or P2MR input, false otherwise. */ export declare function isTaprootInput(input: PsbtInput): boolean; /** - * Checks if a PSBT output is a taproot output. + * Checks if a PSBT output is a taproot or P2MR output. * @param output The PSBT output to check. * @param script The script to check. Optional. - * @returns True if the output is a taproot output, false otherwise. + * @returns True if the output is a taproot or P2MR output, false otherwise. */ export declare function isTaprootOutput(output: PsbtOutput, script?: Uint8Array): boolean; /** diff --git a/src/cjs/psbt/psbtutils.cjs b/src/cjs/psbt/psbtutils.cjs index 6918f0226e..19719e297f 100644 --- a/src/cjs/psbt/psbtutils.cjs +++ b/src/cjs/psbt/psbtutils.cjs @@ -44,7 +44,8 @@ var __importStar = return result; }; Object.defineProperty(exports, '__esModule', { value: true }); -exports.isP2TR = +exports.isP2MR = + exports.isP2TR = exports.isP2SHScript = exports.isP2WSHScript = exports.isP2WPKH = @@ -85,6 +86,7 @@ exports.isP2WPKH = isPaymentFactory(payments.p2wpkh); exports.isP2WSHScript = isPaymentFactory(payments.p2wsh); exports.isP2SHScript = isPaymentFactory(payments.p2sh); exports.isP2TR = isPaymentFactory(payments.p2tr); +exports.isP2MR = isPaymentFactory(payments.p2mr); /** * Converts a witness stack to a script witness. * @param witness The witness stack to convert. diff --git a/src/cjs/psbt/psbtutils.d.ts b/src/cjs/psbt/psbtutils.d.ts index 64955e0f5c..efe73304db 100644 --- a/src/cjs/psbt/psbtutils.d.ts +++ b/src/cjs/psbt/psbtutils.d.ts @@ -6,6 +6,7 @@ export declare const isP2WPKH: (script: Uint8Array) => boolean; export declare const isP2WSHScript: (script: Uint8Array) => boolean; export declare const isP2SHScript: (script: Uint8Array) => boolean; export declare const isP2TR: (script: Uint8Array) => boolean; +export declare const isP2MR: (script: Uint8Array) => boolean; /** * Converts a witness stack to a script witness. * @param witness The witness stack to convert. diff --git a/src/esm/address.js b/src/esm/address.js index 5908238fc0..cc2bd9bda3 100644 --- a/src/esm/address.js +++ b/src/esm/address.js @@ -140,6 +140,9 @@ export function fromOutputScript(output, network) { try { return payments.p2tr({ output, network }).address; } catch (e) {} + try { + return payments.p2mr({ output, network }).address; + } catch (e) {} try { return _toFutureSegwitAddress(output, network); } catch (e) {} @@ -179,6 +182,11 @@ export function toOutputScript(address, network) { } else if (decodeBech32.version === 1) { if (decodeBech32.data.length === 32) return payments.p2tr({ pubkey: decodeBech32.data }).output; + } else if ( + decodeBech32.version === 2 && + decodeBech32.data.length === 32 + ) { + return payments.p2mr({ hash: decodeBech32.data }).output; } else if ( decodeBech32.version >= FUTURE_SEGWIT_MIN_VERSION && decodeBech32.version <= FUTURE_SEGWIT_MAX_VERSION && diff --git a/src/esm/payments/bip341.js b/src/esm/payments/bip341.js index 2317358b1a..750b7f9e7e 100644 --- a/src/esm/payments/bip341.js +++ b/src/esm/payments/bip341.js @@ -30,6 +30,36 @@ export function rootHashFromPath(controlBlock, leafHash) { } return kj; } +/** + * Calculates the root hash from a P2MR control block and leaf hash. + * Unlike P2TR, P2MR control blocks do not include an internal public key, + * so the Merkle path starts at byte offset 1 (after the control byte). + * @param controlBlock - The P2MR control block buffer (1 + 32*m bytes). + * @param leafHash - The leaf hash buffer. + * @returns The root hash buffer. + * @throws {TypeError} If the control block length is invalid. + */ +export function rootHashFromP2MRPath(controlBlock, leafHash) { + if (controlBlock.length < 1) + throw new TypeError( + `The control-block length is too small. Got ${controlBlock.length}, expected min 1.`, + ); + if ((controlBlock.length - 1) % 32 !== 0) + throw new TypeError( + `The control-block length of ${controlBlock.length} is incorrect for P2MR!`, + ); + const m = (controlBlock.length - 1) / 32; + let kj = leafHash; + for (let j = 0; j < m; j++) { + const ej = controlBlock.slice(1 + 32 * j, 33 + 32 * j); + if (tools.compare(kj, ej) < 0) { + kj = tapBranchHash(kj, ej); + } else { + kj = tapBranchHash(ej, kj); + } + } + return kj; +} /** * Build a hash tree of merkle nodes from the scripts binary tree. * @param scriptTree - the tree of scripts to pairwise hash. @@ -117,7 +147,7 @@ export function tweakKey(pubKey, h) { * @param b - The second buffer. * @returns The TapBranch hash of the concatenated buffers. */ -function tapBranchHash(a, b) { +export function tapBranchHash(a, b) { return bcrypto.taggedHash('TapBranch', tools.concat([a, b])); } /** diff --git a/src/esm/payments/index.js b/src/esm/payments/index.js index 3c2fd1e5e7..fd9ea32eb7 100644 --- a/src/esm/payments/index.js +++ b/src/esm/payments/index.js @@ -6,6 +6,7 @@ import { p2sh } from './p2sh.js'; import { p2wpkh } from './p2wpkh.js'; import { p2wsh } from './p2wsh.js'; import { p2tr } from './p2tr.js'; -export { embed, p2ms, p2pk, p2pkh, p2sh, p2wpkh, p2wsh, p2tr }; +import { p2mr } from './p2mr.js'; +export { embed, p2ms, p2pk, p2pkh, p2sh, p2wpkh, p2wsh, p2tr, p2mr }; // TODO // witness commitment diff --git a/src/esm/payments/p2mr.js b/src/esm/payments/p2mr.js new file mode 100644 index 0000000000..bcde079a8a --- /dev/null +++ b/src/esm/payments/p2mr.js @@ -0,0 +1,256 @@ +import { bitcoin as BITCOIN_NETWORK } from '../networks.js'; +import * as bscript from '../script.js'; +import { + isTaptree, + TAPLEAF_VERSION_MASK, + stacksEqual, + NBufferSchemaFactory, + BufferSchema, +} from '../types.js'; +import { + toHashTree, + rootHashFromP2MRPath, + findScriptPath, + tapleafHash, + LEAF_VERSION_TAPSCRIPT, +} from './bip341.js'; +import * as lazy from './lazy.js'; +import { bech32m } from 'bech32'; +import { fromBech32 } from '../address.js'; +import * as tools from 'uint8array-tools'; +import * as v from 'valibot'; +const OPS = bscript.OPS; +const P2MR_WITNESS_VERSION = 0x02; +const ANNEX_PREFIX = 0x50; +/** + * Creates a Pay-to-Merkle-Root (P2MR) payment object (BIP 360). + * + * P2MR is a SegWit version 2 output type that commits directly to the + * Merkle root of a script tree without an internal public key. + * It supports only script-path spending (no key-path spend), + * providing resistance to long exposure quantum attacks. + * + * @param a - The payment object containing the necessary data for P2MR. + * @param opts - Optional payment options. + * @returns The P2MR payment object. + * @throws {TypeError} If the provided data is invalid or insufficient. + */ +export function p2mr(a, opts) { + if ( + !a.address && + !a.output && + !a.hash && + !a.scriptTree && + !(a.witness && a.witness.length > 1) + ) + throw new TypeError('Not enough data'); + opts = Object.assign({ validate: true }, opts || {}); + v.parse( + v.partial( + v.object({ + address: v.string(), + input: NBufferSchemaFactory(0), + network: v.object({}), + output: NBufferSchemaFactory(34), + hash: NBufferSchemaFactory(32), // merkle root of the script tree + witness: v.array(BufferSchema), + scriptTree: v.custom(isTaptree, 'Taptree is not of type isTaptree'), + redeem: v.partial( + v.object({ + output: BufferSchema, // tapleaf script + redeemVersion: v.number(), // tapleaf version + witness: v.array(BufferSchema), + }), + ), + redeemVersion: v.number(), + }), + ), + a, + ); + const _address = lazy.value(() => { + return fromBech32(a.address); + }); + // remove annex if present, ignored by taproot/P2MR + const _witness = lazy.value(() => { + if (!a.witness || !a.witness.length) return; + if ( + a.witness.length >= 2 && + a.witness[a.witness.length - 1][0] === ANNEX_PREFIX + ) { + return a.witness.slice(0, -1); + } + return a.witness.slice(); + }); + const _hashTree = lazy.value(() => { + if (a.scriptTree) return toHashTree(a.scriptTree); + if (a.hash) return { hash: a.hash }; + return; + }); + const network = a.network || BITCOIN_NETWORK; + const o = { name: 'p2mr', network }; + lazy.prop(o, 'address', () => { + if (!o.hash) return; + const words = bech32m.toWords(o.hash); + words.unshift(P2MR_WITNESS_VERSION); + return bech32m.encode(network.bech32, words); + }); + lazy.prop(o, 'hash', () => { + const hashTree = _hashTree(); + if (hashTree) return hashTree.hash; + if (a.output) return a.output.slice(2); + if (a.address) return _address().data; + const w = _witness(); + if (w && w.length > 1) { + const controlBlock = w[w.length - 1]; + const leafVersion = controlBlock[0] & TAPLEAF_VERSION_MASK; + const script = w[w.length - 2]; + const leafHash = tapleafHash({ output: script, version: leafVersion }); + return rootHashFromP2MRPath(controlBlock, leafHash); + } + return null; + }); + lazy.prop(o, 'output', () => { + if (!o.hash) return; + // P2MR scriptPubKey: OP_2 OP_PUSHBYTES_32 <32-byte merkle_root> + return bscript.compile([OPS.OP_2, o.hash]); + }); + lazy.prop(o, 'redeemVersion', () => { + if (a.redeemVersion) return a.redeemVersion; + if ( + a.redeem && + a.redeem.redeemVersion !== undefined && + a.redeem.redeemVersion !== null + ) { + return a.redeem.redeemVersion; + } + return LEAF_VERSION_TAPSCRIPT; + }); + lazy.prop(o, 'redeem', () => { + const witness = _witness(); // witness without annex + if (!witness || witness.length < 2) return; + return { + output: witness[witness.length - 2], + witness: witness.slice(0, -2), + redeemVersion: witness[witness.length - 1][0] & TAPLEAF_VERSION_MASK, + }; + }); + lazy.prop(o, 'witness', () => { + if (a.witness) return a.witness; + const hashTree = _hashTree(); + if (hashTree && a.redeem && a.redeem.output) { + const leafHash = tapleafHash({ + output: a.redeem.output, + version: o.redeemVersion, + }); + const path = findScriptPath(hashTree, leafHash); + if (!path) return; + // P2MR control block: [control_byte] [32*m byte merkle_path] + // No internal pubkey (unlike P2TR). + // Parity bit is always 1 for P2MR. + const controlBlock = tools.concat( + [Uint8Array.from([o.redeemVersion | 1])].concat(path), + ); + return [a.redeem.output, controlBlock]; + } + }); + // extended validation + if (opts.validate) { + let hash = Uint8Array.from([]); + if (a.address) { + if (network && network.bech32 !== _address().prefix) + throw new TypeError('Invalid prefix or Network mismatch'); + if (_address().version !== P2MR_WITNESS_VERSION) + throw new TypeError('Invalid address version'); + if (_address().data.length !== 32) + throw new TypeError('Invalid address data'); + hash = _address().data; + } + if (a.hash) { + if (hash.length > 0 && tools.compare(hash, a.hash) !== 0) + throw new TypeError('Hash mismatch'); + else hash = a.hash; + } + if (a.output) { + if ( + a.output.length !== 34 || + a.output[0] !== OPS.OP_2 || + a.output[1] !== 0x20 + ) + throw new TypeError('Output is invalid'); + if (hash.length > 0 && tools.compare(hash, a.output.slice(2)) !== 0) + throw new TypeError('Hash mismatch'); + else hash = a.output.slice(2); + } + const hashTree = _hashTree(); + if (a.hash && hashTree) { + if (tools.compare(a.hash, hashTree.hash) !== 0) + throw new TypeError('Hash mismatch'); + } + if (a.redeem && a.redeem.output && hashTree) { + const leafHash = tapleafHash({ + output: a.redeem.output, + version: o.redeemVersion, + }); + if (!findScriptPath(hashTree, leafHash)) + throw new TypeError('Redeem script not in tree'); + } + const witness = _witness(); + // compare the provided redeem data with the one computed from witness + if (a.redeem && o.redeem) { + if (a.redeem.redeemVersion) { + if (a.redeem.redeemVersion !== o.redeem.redeemVersion) + throw new TypeError('Redeem.redeemVersion and witness mismatch'); + } + if (a.redeem.output) { + if (bscript.decompile(a.redeem.output).length === 0) + throw new TypeError('Redeem.output is invalid'); + // output redeem is constructed from the witness + if ( + o.redeem.output && + tools.compare(a.redeem.output, o.redeem.output) !== 0 + ) + throw new TypeError('Redeem.output and witness mismatch'); + } + if (a.redeem.witness) { + if ( + o.redeem.witness && + !stacksEqual(a.redeem.witness, o.redeem.witness) + ) + throw new TypeError('Redeem.witness and witness mismatch'); + } + } + if (witness && witness.length) { + // P2MR only supports script-path spending (no key-path) + if (witness.length < 2) { + throw new TypeError( + 'P2MR does not support key-path spending. Witness must have at least 2 elements.', + ); + } + // Script-path spending validation + const controlBlock = witness[witness.length - 1]; + if (controlBlock.length < 1) + throw new TypeError( + `The control-block length is too small. Got ${controlBlock.length}, expected min 1.`, + ); + if ((controlBlock.length - 1) % 32 !== 0) + throw new TypeError( + `The control-block length of ${controlBlock.length} is incorrect!`, + ); + const m = (controlBlock.length - 1) / 32; + if (m > 128) + throw new TypeError( + `The script path is too long. Got ${m}, expected max 128.`, + ); + // P2MR parity bit must always be 1 + if ((controlBlock[0] & 1) !== 1) + throw new TypeError('P2MR control block parity bit must be 1'); + const leafVersion = controlBlock[0] & TAPLEAF_VERSION_MASK; + const script = witness[witness.length - 2]; + const leafHash = tapleafHash({ output: script, version: leafVersion }); + const rootHash = rootHashFromP2MRPath(controlBlock, leafHash); + if (hash.length && tools.compare(hash, rootHash) !== 0) + throw new TypeError('Hash mismatch for p2mr witness'); + } + } + return Object.assign(o, a); +} diff --git a/src/esm/psbt/bip371.js b/src/esm/psbt/bip371.js index dd40bbfeb5..19144fc657 100644 --- a/src/esm/psbt/bip371.js +++ b/src/esm/psbt/bip371.js @@ -4,11 +4,13 @@ import { witnessStackToScriptWitness, pubkeyPositionInScript, isP2TR, + isP2MR, } from './psbtutils.js'; import { tweakKey, tapleafHash, rootHashFromPath, + rootHashFromP2MRPath, LEAF_VERSION_TAPSCRIPT, MAX_TAPTREE_DEPTH, } from '../payments/bip341.js'; @@ -58,9 +60,11 @@ export function serializeTaprootSignature(sig, sighashType) { return tools.concat([sig, sighashTypeByte]); } /** - * Checks if a PSBT input is a taproot input. + * Checks if a PSBT input is a taproot or P2MR input. + * P2MR inputs use the same PSBT fields as taproot (tapLeafScript, tapScriptSig, etc.) + * but without tapInternalKey (no key-path spend). * @param input The PSBT input to check. - * @returns True if the input is a taproot input, false otherwise. + * @returns True if the input is a taproot or P2MR input, false otherwise. */ export function isTaprootInput(input) { return ( @@ -70,15 +74,16 @@ export function isTaprootInput(input) { input.tapMerkleRoot || (input.tapLeafScript && input.tapLeafScript.length) || (input.tapBip32Derivation && input.tapBip32Derivation.length) || - (input.witnessUtxo && isP2TR(input.witnessUtxo.script)) + (input.witnessUtxo && + (isP2TR(input.witnessUtxo.script) || isP2MR(input.witnessUtxo.script))) ) ); } /** - * Checks if a PSBT output is a taproot output. + * Checks if a PSBT output is a taproot or P2MR output. * @param output The PSBT output to check. * @param script The script to check. Optional. - * @returns True if the output is a taproot output, false otherwise. + * @returns True if the output is a taproot or P2MR output, false otherwise. */ export function isTaprootOutput(output, script) { return ( @@ -87,7 +92,7 @@ export function isTaprootOutput(output, script) { output.tapInternalKey || output.tapTree || (output.tapBip32Derivation && output.tapBip32Derivation.length) || - (script && isP2TR(script)) + (script && (isP2TR(script) || isP2MR(script))) ) ); } @@ -385,6 +390,8 @@ function checkIfTapLeafInTree(inputData, newInputData, action) { } /** * Checks if a TapLeafScript is present in a Merkle tree. + * Supports both P2TR control blocks (with internal pubkey) and + * P2MR control blocks (without internal pubkey). * @param tapLeaf The TapLeafScript to check. * @param merkleRoot The Merkle root of the tree. If not provided, the function assumes the TapLeafScript is present. * @returns A boolean indicating whether the TapLeafScript is present in the tree. @@ -395,8 +402,17 @@ function isTapLeafInTree(tapLeaf, merkleRoot) { output: tapLeaf.script, version: tapLeaf.leafVersion, }); - const rootHash = rootHashFromPath(tapLeaf.controlBlock, leafHash); - return tools.compare(rootHash, merkleRoot) === 0; + // Try P2TR format first (control block includes 32-byte internal pubkey) + try { + const rootHash = rootHashFromPath(tapLeaf.controlBlock, leafHash); + if (tools.compare(rootHash, merkleRoot) === 0) return true; + } catch (e) {} + // Try P2MR format (control block has no internal pubkey, offset 1) + try { + const rootHash = rootHashFromP2MRPath(tapLeaf.controlBlock, leafHash); + if (tools.compare(rootHash, merkleRoot) === 0) return true; + } catch (e) {} + return false; } /** * Sorts the signatures in the input's tapScriptSig array based on their position in the tapLeaf script. diff --git a/src/esm/psbt/psbtutils.js b/src/esm/psbt/psbtutils.js index fa4bc27588..0988c351a6 100644 --- a/src/esm/psbt/psbtutils.js +++ b/src/esm/psbt/psbtutils.js @@ -26,6 +26,7 @@ export const isP2WPKH = isPaymentFactory(payments.p2wpkh); export const isP2WSHScript = isPaymentFactory(payments.p2wsh); export const isP2SHScript = isPaymentFactory(payments.p2sh); export const isP2TR = isPaymentFactory(payments.p2tr); +export const isP2MR = isPaymentFactory(payments.p2mr); /** * Converts a witness stack to a script witness. * @param witness The witness stack to convert. diff --git a/ts_src/address.ts b/ts_src/address.ts index 9b551e0d51..7add3d9e07 100644 --- a/ts_src/address.ts +++ b/ts_src/address.ts @@ -27,11 +27,11 @@ export interface Base58CheckResult { /** bech32 decode result */ export interface Bech32Result { - /** address version: 0x00 for P2WPKH、P2WSH, 0x01 for P2TR*/ + /** address version: 0x00 for P2WPKH/P2WSH, 0x01 for P2TR, 0x02 for P2MR */ version: number; - /** address prefix: bc for P2WPKH、P2WSH、P2TR */ + /** address prefix: bc for P2WPKH/P2WSH/P2TR/P2MR */ prefix: string; - /** address data:20 bytes for P2WPKH, 32 bytes for P2WSH、P2TR */ + /** address data:20 bytes for P2WPKH, 32 bytes for P2WSH/P2TR/P2MR */ data: Uint8Array; } @@ -198,6 +198,9 @@ export function fromOutputScript( try { return payments.p2tr({ output, network }).address as string; } catch (e) {} + try { + return payments.p2mr({ output, network }).address as string; + } catch (e) {} try { return _toFutureSegwitAddress(output, network); } catch (e) {} @@ -245,6 +248,11 @@ export function toOutputScript(address: string, network?: Network): Uint8Array { if (decodeBech32.data.length === 32) return payments.p2tr({ pubkey: decodeBech32.data }) .output as Uint8Array; + } else if ( + decodeBech32.version === 2 && + decodeBech32.data.length === 32 + ) { + return payments.p2mr({ hash: decodeBech32.data }).output as Uint8Array; } else if ( decodeBech32.version >= FUTURE_SEGWIT_MIN_VERSION && decodeBech32.version <= FUTURE_SEGWIT_MAX_VERSION && diff --git a/ts_src/payments/bip341.ts b/ts_src/payments/bip341.ts index 5e6b4e7e5e..98cf301679 100644 --- a/ts_src/payments/bip341.ts +++ b/ts_src/payments/bip341.ts @@ -64,6 +64,42 @@ export function rootHashFromPath( return kj; } +/** + * Calculates the root hash from a P2MR control block and leaf hash. + * Unlike P2TR, P2MR control blocks do not include an internal public key, + * so the Merkle path starts at byte offset 1 (after the control byte). + * @param controlBlock - The P2MR control block buffer (1 + 32*m bytes). + * @param leafHash - The leaf hash buffer. + * @returns The root hash buffer. + * @throws {TypeError} If the control block length is invalid. + */ +export function rootHashFromP2MRPath( + controlBlock: Uint8Array, + leafHash: Uint8Array, +): Uint8Array { + if (controlBlock.length < 1) + throw new TypeError( + `The control-block length is too small. Got ${controlBlock.length}, expected min 1.`, + ); + if ((controlBlock.length - 1) % 32 !== 0) + throw new TypeError( + `The control-block length of ${controlBlock.length} is incorrect for P2MR!`, + ); + const m = (controlBlock.length - 1) / 32; + + let kj = leafHash; + for (let j = 0; j < m; j++) { + const ej = controlBlock.slice(1 + 32 * j, 33 + 32 * j); + if (tools.compare(kj, ej) < 0) { + kj = tapBranchHash(kj, ej); + } else { + kj = tapBranchHash(ej, kj); + } + } + + return kj; +} + /** * Build a hash tree of merkle nodes from the scripts binary tree. * @param scriptTree - the tree of scripts to pairwise hash. @@ -170,7 +206,7 @@ export function tweakKey( * @param b - The second buffer. * @returns The TapBranch hash of the concatenated buffers. */ -function tapBranchHash(a: Uint8Array, b: Uint8Array): Uint8Array { +export function tapBranchHash(a: Uint8Array, b: Uint8Array): Uint8Array { return bcrypto.taggedHash('TapBranch', tools.concat([a, b])); } diff --git a/ts_src/payments/index.ts b/ts_src/payments/index.ts index a9d79981f3..444328091a 100644 --- a/ts_src/payments/index.ts +++ b/ts_src/payments/index.ts @@ -7,6 +7,7 @@ * - P2WPKH (Pay-to-Witness-PubKey-Hash) * - P2WSH (Pay-to-Witness-Script-Hash) * - P2TR (Taproot) + * - P2MR (Pay-to-Merkle-Root, BIP 360) * * The `Payment` interface defines the structure of a payment object used for constructing various * payment types, with fields for signatures, public keys, redeem scripts, and more. @@ -23,6 +24,7 @@ import { p2sh } from './p2sh.js'; import { p2wpkh } from './p2wpkh.js'; import { p2wsh } from './p2wsh.js'; import { p2tr } from './p2tr.js'; +import { p2mr } from './p2mr.js'; export interface Payment { name?: string; @@ -58,7 +60,7 @@ export type StackElement = Uint8Array | number; export type Stack = StackElement[]; export type StackFunction = () => Stack; -export { embed, p2ms, p2pk, p2pkh, p2sh, p2wpkh, p2wsh, p2tr }; +export { embed, p2ms, p2pk, p2pkh, p2sh, p2wpkh, p2wsh, p2tr, p2mr }; // TODO // witness commitment diff --git a/ts_src/payments/p2mr.ts b/ts_src/payments/p2mr.ts new file mode 100644 index 0000000000..3199750716 --- /dev/null +++ b/ts_src/payments/p2mr.ts @@ -0,0 +1,294 @@ +import { bitcoin as BITCOIN_NETWORK } from '../networks.js'; +import * as bscript from '../script.js'; +import { + isTaptree, + TAPLEAF_VERSION_MASK, + stacksEqual, + NBufferSchemaFactory, + BufferSchema, +} from '../types.js'; +import { + toHashTree, + rootHashFromP2MRPath, + findScriptPath, + tapleafHash, + LEAF_VERSION_TAPSCRIPT, +} from './bip341.js'; +import { Payment, PaymentOpts } from './index.js'; +import * as lazy from './lazy.js'; +import { bech32m } from 'bech32'; +import { fromBech32 } from '../address.js'; +import * as tools from 'uint8array-tools'; +import * as v from 'valibot'; + +const OPS = bscript.OPS; +const P2MR_WITNESS_VERSION = 0x02; +const ANNEX_PREFIX = 0x50; + +/** + * Creates a Pay-to-Merkle-Root (P2MR) payment object (BIP 360). + * + * P2MR is a SegWit version 2 output type that commits directly to the + * Merkle root of a script tree without an internal public key. + * It supports only script-path spending (no key-path spend), + * providing resistance to long exposure quantum attacks. + * + * @param a - The payment object containing the necessary data for P2MR. + * @param opts - Optional payment options. + * @returns The P2MR payment object. + * @throws {TypeError} If the provided data is invalid or insufficient. + */ +export function p2mr(a: Payment, opts?: PaymentOpts): Payment { + if ( + !a.address && + !a.output && + !a.hash && + !a.scriptTree && + !(a.witness && a.witness.length > 1) + ) + throw new TypeError('Not enough data'); + + opts = Object.assign({ validate: true }, opts || {}); + + v.parse( + v.partial( + v.object({ + address: v.string(), + input: NBufferSchemaFactory(0), + network: v.object({}), + output: NBufferSchemaFactory(34), + hash: NBufferSchemaFactory(32), // merkle root of the script tree + witness: v.array(BufferSchema), + scriptTree: v.custom(isTaptree, 'Taptree is not of type isTaptree'), + redeem: v.partial( + v.object({ + output: BufferSchema, // tapleaf script + redeemVersion: v.number(), // tapleaf version + witness: v.array(BufferSchema), + }), + ), + redeemVersion: v.number(), + }), + ), + a, + ); + + const _address = lazy.value(() => { + return fromBech32(a.address!); + }); + + // remove annex if present, ignored by taproot/P2MR + const _witness = lazy.value(() => { + if (!a.witness || !a.witness.length) return; + if ( + a.witness.length >= 2 && + a.witness[a.witness.length - 1][0] === ANNEX_PREFIX + ) { + return a.witness.slice(0, -1); + } + return a.witness.slice(); + }); + + const _hashTree = lazy.value(() => { + if (a.scriptTree) return toHashTree(a.scriptTree); + if (a.hash) return { hash: a.hash }; + return; + }); + + const network = a.network || BITCOIN_NETWORK; + const o: Payment = { name: 'p2mr', network }; + + lazy.prop(o, 'address', () => { + if (!o.hash) return; + + const words = bech32m.toWords(o.hash); + words.unshift(P2MR_WITNESS_VERSION); + return bech32m.encode(network.bech32, words); + }); + + lazy.prop(o, 'hash', () => { + const hashTree = _hashTree(); + if (hashTree) return hashTree.hash; + if (a.output) return a.output.slice(2); + if (a.address) return _address().data; + const w = _witness(); + if (w && w.length > 1) { + const controlBlock = w[w.length - 1]; + const leafVersion = controlBlock[0] & TAPLEAF_VERSION_MASK; + const script = w[w.length - 2]; + const leafHash = tapleafHash({ output: script, version: leafVersion }); + return rootHashFromP2MRPath(controlBlock, leafHash); + } + return null; + }); + + lazy.prop(o, 'output', () => { + if (!o.hash) return; + // P2MR scriptPubKey: OP_2 OP_PUSHBYTES_32 <32-byte merkle_root> + return bscript.compile([OPS.OP_2, o.hash]); + }); + + lazy.prop(o, 'redeemVersion', () => { + if (a.redeemVersion) return a.redeemVersion; + if ( + a.redeem && + a.redeem.redeemVersion !== undefined && + a.redeem.redeemVersion !== null + ) { + return a.redeem.redeemVersion; + } + + return LEAF_VERSION_TAPSCRIPT; + }); + + lazy.prop(o, 'redeem', () => { + const witness = _witness(); // witness without annex + if (!witness || witness.length < 2) return; + + return { + output: witness[witness.length - 2], + witness: witness.slice(0, -2), + redeemVersion: witness[witness.length - 1][0] & TAPLEAF_VERSION_MASK, + }; + }); + + lazy.prop(o, 'witness', () => { + if (a.witness) return a.witness; + const hashTree = _hashTree(); + if (hashTree && a.redeem && a.redeem.output) { + const leafHash = tapleafHash({ + output: a.redeem.output, + version: o.redeemVersion, + }); + const path = findScriptPath(hashTree, leafHash); + if (!path) return; + // P2MR control block: [control_byte] [32*m byte merkle_path] + // No internal pubkey (unlike P2TR). + // Parity bit is always 1 for P2MR. + const controlBlock = tools.concat( + [Uint8Array.from([o.redeemVersion! | 1])].concat(path), + ); + return [a.redeem.output, controlBlock]; + } + }); + + // extended validation + if (opts.validate) { + let hash: Uint8Array = Uint8Array.from([]); + + if (a.address) { + if (network && network.bech32 !== _address().prefix) + throw new TypeError('Invalid prefix or Network mismatch'); + if (_address().version !== P2MR_WITNESS_VERSION) + throw new TypeError('Invalid address version'); + if (_address().data.length !== 32) + throw new TypeError('Invalid address data'); + hash = _address().data; + } + + if (a.hash) { + if (hash.length > 0 && tools.compare(hash, a.hash) !== 0) + throw new TypeError('Hash mismatch'); + else hash = a.hash; + } + + if (a.output) { + if ( + a.output.length !== 34 || + a.output[0] !== OPS.OP_2 || + a.output[1] !== 0x20 + ) + throw new TypeError('Output is invalid'); + if (hash.length > 0 && tools.compare(hash, a.output.slice(2)) !== 0) + throw new TypeError('Hash mismatch'); + else hash = a.output.slice(2); + } + + const hashTree = _hashTree(); + + if (a.hash && hashTree) { + if (tools.compare(a.hash, hashTree.hash) !== 0) + throw new TypeError('Hash mismatch'); + } + + if (a.redeem && a.redeem.output && hashTree) { + const leafHash = tapleafHash({ + output: a.redeem.output, + version: o.redeemVersion, + }); + if (!findScriptPath(hashTree, leafHash)) + throw new TypeError('Redeem script not in tree'); + } + + const witness = _witness(); + + // compare the provided redeem data with the one computed from witness + if (a.redeem && o.redeem) { + if (a.redeem.redeemVersion) { + if (a.redeem.redeemVersion !== o.redeem.redeemVersion) + throw new TypeError('Redeem.redeemVersion and witness mismatch'); + } + + if (a.redeem.output) { + if (bscript.decompile(a.redeem.output)!.length === 0) + throw new TypeError('Redeem.output is invalid'); + + // output redeem is constructed from the witness + if ( + o.redeem.output && + tools.compare(a.redeem.output, o.redeem.output) !== 0 + ) + throw new TypeError('Redeem.output and witness mismatch'); + } + if (a.redeem.witness) { + if ( + o.redeem.witness && + !stacksEqual(a.redeem.witness, o.redeem.witness) + ) + throw new TypeError('Redeem.witness and witness mismatch'); + } + } + + if (witness && witness.length) { + // P2MR only supports script-path spending (no key-path) + if (witness.length < 2) { + throw new TypeError( + 'P2MR does not support key-path spending. Witness must have at least 2 elements.', + ); + } + + // Script-path spending validation + const controlBlock = witness[witness.length - 1]; + if (controlBlock.length < 1) + throw new TypeError( + `The control-block length is too small. Got ${controlBlock.length}, expected min 1.`, + ); + + if ((controlBlock.length - 1) % 32 !== 0) + throw new TypeError( + `The control-block length of ${controlBlock.length} is incorrect!`, + ); + + const m = (controlBlock.length - 1) / 32; + if (m > 128) + throw new TypeError( + `The script path is too long. Got ${m}, expected max 128.`, + ); + + // P2MR parity bit must always be 1 + if ((controlBlock[0] & 1) !== 1) + throw new TypeError('P2MR control block parity bit must be 1'); + + const leafVersion = controlBlock[0] & TAPLEAF_VERSION_MASK; + const script = witness[witness.length - 2]; + + const leafHash = tapleafHash({ output: script, version: leafVersion }); + const rootHash = rootHashFromP2MRPath(controlBlock, leafHash); + + if (hash.length && tools.compare(hash, rootHash) !== 0) + throw new TypeError('Hash mismatch for p2mr witness'); + } + } + + return Object.assign(o, a); +} diff --git a/ts_src/psbt/bip371.ts b/ts_src/psbt/bip371.ts index 4fd5271a50..f2bd889ccf 100644 --- a/ts_src/psbt/bip371.ts +++ b/ts_src/psbt/bip371.ts @@ -15,11 +15,13 @@ import { witnessStackToScriptWitness, pubkeyPositionInScript, isP2TR, + isP2MR, } from './psbtutils.js'; import { tweakKey, tapleafHash, rootHashFromPath, + rootHashFromP2MRPath, LEAF_VERSION_TAPSCRIPT, MAX_TAPTREE_DEPTH, } from '../payments/bip341.js'; @@ -84,9 +86,11 @@ export function serializeTaprootSignature( } /** - * Checks if a PSBT input is a taproot input. + * Checks if a PSBT input is a taproot or P2MR input. + * P2MR inputs use the same PSBT fields as taproot (tapLeafScript, tapScriptSig, etc.) + * but without tapInternalKey (no key-path spend). * @param input The PSBT input to check. - * @returns True if the input is a taproot input, false otherwise. + * @returns True if the input is a taproot or P2MR input, false otherwise. */ export function isTaprootInput(input: PsbtInput): boolean { return ( @@ -96,16 +100,17 @@ export function isTaprootInput(input: PsbtInput): boolean { input.tapMerkleRoot || (input.tapLeafScript && input.tapLeafScript.length) || (input.tapBip32Derivation && input.tapBip32Derivation.length) || - (input.witnessUtxo && isP2TR(input.witnessUtxo.script)) + (input.witnessUtxo && + (isP2TR(input.witnessUtxo.script) || isP2MR(input.witnessUtxo.script))) ) ); } /** - * Checks if a PSBT output is a taproot output. + * Checks if a PSBT output is a taproot or P2MR output. * @param output The PSBT output to check. * @param script The script to check. Optional. - * @returns True if the output is a taproot output, false otherwise. + * @returns True if the output is a taproot or P2MR output, false otherwise. */ export function isTaprootOutput( output: PsbtOutput, @@ -117,7 +122,7 @@ export function isTaprootOutput( output.tapInternalKey || output.tapTree || (output.tapBip32Derivation && output.tapBip32Derivation.length) || - (script && isP2TR(script)) + (script && (isP2TR(script) || isP2MR(script))) ) ); } @@ -486,6 +491,8 @@ function checkIfTapLeafInTree( /** * Checks if a TapLeafScript is present in a Merkle tree. + * Supports both P2TR control blocks (with internal pubkey) and + * P2MR control blocks (without internal pubkey). * @param tapLeaf The TapLeafScript to check. * @param merkleRoot The Merkle root of the tree. If not provided, the function assumes the TapLeafScript is present. * @returns A boolean indicating whether the TapLeafScript is present in the tree. @@ -501,8 +508,19 @@ function isTapLeafInTree( version: tapLeaf.leafVersion, }); - const rootHash = rootHashFromPath(tapLeaf.controlBlock, leafHash); - return tools.compare(rootHash, merkleRoot) === 0; + // Try P2TR format first (control block includes 32-byte internal pubkey) + try { + const rootHash = rootHashFromPath(tapLeaf.controlBlock, leafHash); + if (tools.compare(rootHash, merkleRoot) === 0) return true; + } catch (e) {} + + // Try P2MR format (control block has no internal pubkey, offset 1) + try { + const rootHash = rootHashFromP2MRPath(tapLeaf.controlBlock, leafHash); + if (tools.compare(rootHash, merkleRoot) === 0) return true; + } catch (e) {} + + return false; } /** diff --git a/ts_src/psbt/psbtutils.ts b/ts_src/psbt/psbtutils.ts index 298f4769c0..2ad6e854b0 100644 --- a/ts_src/psbt/psbtutils.ts +++ b/ts_src/psbt/psbtutils.ts @@ -29,6 +29,7 @@ export const isP2WPKH = isPaymentFactory(payments.p2wpkh); export const isP2WSHScript = isPaymentFactory(payments.p2wsh); export const isP2SHScript = isPaymentFactory(payments.p2sh); export const isP2TR = isPaymentFactory(payments.p2tr); +export const isP2MR = isPaymentFactory(payments.p2mr); /** * Converts a witness stack to a script witness. From 3c09f79ed546cbc235a4870b99d5c7974ac12f49 Mon Sep 17 00:00:00 2001 From: jasonandjay <342690199@qq.com> Date: Sun, 15 Feb 2026 10:43:20 +0800 Subject: [PATCH 2/4] test: add P2MR unit tests with BIP 360 test vectors Cover all 6 official BIP 360 valid test vectors for address, scriptPubKey, and Merkle root derivation. Add 12 witness/controlBlock tests verifying script-path spending for every leaf in each tree topology (single leaf, two-leaf same/different version, lightning contract, three-leaf complex and alternative). Include 6 negative tests for error handling. Note: the official BIP 360 test vector for p2mr_different_version_leaves has a known bug (control byte 0xC1 instead of correct 0xFB for leaf version 250). Our test uses the correct value. --- test/fixtures/p2mr.json | 684 ++++++++++++++++++++++++++++++++++++++++ test/payments.spec.ts | 7 + 2 files changed, 691 insertions(+) create mode 100644 test/fixtures/p2mr.json diff --git a/test/fixtures/p2mr.json b/test/fixtures/p2mr.json new file mode 100644 index 0000000000..426366db2f --- /dev/null +++ b/test/fixtures/p2mr.json @@ -0,0 +1,684 @@ +{ + "valid": [ + { + "description": "P2MR: output and hash from address (single leaf)", + "arguments": { + "address": "bc1zc5jhzjnlf8pg4mdmhfuvqpvnr2quyd9j7mye5uly6psg9twghu4ssr0v9k" + }, + "options": {}, + "expected": { + "name": "p2mr", + "output": "OP_2 c525714a7f49c28aedbbba78c005931a81c234b2f6c99a73e4d06082adc8bf2b", + "hash": "c525714a7f49c28aedbbba78c005931a81c234b2f6c99a73e4d06082adc8bf2b", + "input": null, + "witness": null + } + }, + { + "description": "P2MR: address and hash from output (single leaf)", + "arguments": { + "output": "OP_2 c525714a7f49c28aedbbba78c005931a81c234b2f6c99a73e4d06082adc8bf2b" + }, + "options": {}, + "expected": { + "name": "p2mr", + "address": "bc1zc5jhzjnlf8pg4mdmhfuvqpvnr2quyd9j7mye5uly6psg9twghu4ssr0v9k", + "hash": "c525714a7f49c28aedbbba78c005931a81c234b2f6c99a73e4d06082adc8bf2b", + "input": null, + "witness": null + } + }, + { + "description": "P2MR: address and output from hash (single leaf)", + "arguments": { + "hash": "c525714a7f49c28aedbbba78c005931a81c234b2f6c99a73e4d06082adc8bf2b" + }, + "options": {}, + "expected": { + "name": "p2mr", + "address": "bc1zc5jhzjnlf8pg4mdmhfuvqpvnr2quyd9j7mye5uly6psg9twghu4ssr0v9k", + "output": "OP_2 c525714a7f49c28aedbbba78c005931a81c234b2f6c99a73e4d06082adc8bf2b", + "input": null, + "witness": null + } + }, + { + "description": "P2MR: single leaf script tree - BIP360 test vector", + "arguments": { + "scriptTree": { + "output": "b617298552a72ade070667e86ca63b8f5789a9fe8731ef91202a91c9f3459007 OP_CHECKSIG", + "version": 192 + } + }, + "options": {}, + "expected": { + "name": "p2mr", + "address": "bc1zc5jhzjnlf8pg4mdmhfuvqpvnr2quyd9j7mye5uly6psg9twghu4ssr0v9k", + "output": "OP_2 c525714a7f49c28aedbbba78c005931a81c234b2f6c99a73e4d06082adc8bf2b", + "hash": "c525714a7f49c28aedbbba78c005931a81c234b2f6c99a73e4d06082adc8bf2b", + "input": null, + "witness": null + } + }, + { + "description": "P2MR: two leaf same version - BIP360 test vector", + "arguments": { + "scriptTree": [ + { + "output": "44b178d64c32c4a05cc4f4d1407268f764c940d20ce97abfd44db5c3592b72fd OP_CHECKSIG", + "version": 192 + }, + { + "output": "546170726f6f74", + "version": 192 + } + ] + }, + "options": {}, + "expected": { + "name": "p2mr", + "address": "bc1z4vtegvwz35ak37me39tl4a2f045u3q7xlv0pek0czjpas7avjrxqz20g2y", + "output": "OP_2 ab179431c28d3b68fb798957faf5497d69c883c6fb1e1cd9f81483d87bac90cc", + "hash": "ab179431c28d3b68fb798957faf5497d69c883c6fb1e1cd9f81483d87bac90cc", + "input": null, + "witness": null + } + }, + { + "description": "P2MR: two different version leaves - BIP360 test vector", + "arguments": { + "scriptTree": [ + { + "output": "387671353e273264c495656e27e39ba899ea8fee3bb69fb2a680e22093447d48 OP_CHECKSIG", + "version": 192 + }, + { + "output": "424950333431", + "version": 250 + } + ] + }, + "options": {}, + "expected": { + "name": "p2mr", + "address": "bc1zdskuzp4ts94h87ws0c7drmev3sf9dagewj8qsylyahfyqhf800hsam4d6e", + "output": "OP_2 6c2dc106ab816b73f9d07e3cd1ef2c8c1256f519748e0813e4edd2405d277bef", + "hash": "6c2dc106ab816b73f9d07e3cd1ef2c8c1256f519748e0813e4edd2405d277bef", + "input": null, + "witness": null + } + }, + { + "description": "P2MR: simple lightning contract - BIP360 test vector", + "arguments": { + "scriptTree": [ + { + "output": "9000 OP_CHECKSEQUENCEVERIFY OP_DROP 9997a497d964fc1a62885b05a51166a65a90df00492c8d7cf61d6accf54803be OP_CHECKSIG", + "version": 192 + }, + { + "output": "OP_SHA256 6c60f404f8167a38fc70eaf8aa17ac351023bef86bcb9d1086a19afe95bd5333 OP_EQUALVERIFY 4edfcf9dfe6c0b5c83d1ab3f78d1b39a46ebac6798e08e19761f5ed89ec83c10 OP_CHECKSIG", + "version": 192 + } + ] + }, + "options": {}, + "expected": { + "name": "p2mr", + "address": "bc1zg9jxlrqlu25kmkkh74r3h387uldfs72wlrz95n60c6j4n4svna4s4lhfhe", + "output": "OP_2 41646f8c1fe2a96ddad7f5471bc4fee7da98794ef8c45a4f4fc6a559d60c9f6b", + "hash": "41646f8c1fe2a96ddad7f5471bc4fee7da98794ef8c45a4f4fc6a559d60c9f6b", + "input": null, + "witness": null + } + }, + { + "description": "P2MR: three leaf complex tree - BIP360 test vector", + "arguments": { + "scriptTree": [ + { + "output": "72ea6adcf1d371dea8fba1035a09f3d24ed5a059799bae114084130ee5898e69 OP_CHECKSIG", + "version": 192 + }, + [ + { + "output": "2352d137f2f3ab38d1eaa976758873377fa5ebb817372c71e2c542313d4abda8 OP_CHECKSIG", + "version": 192 + }, + { + "output": "7337c0dd4253cb86f2c43a2351aadd82cccb12a172cd120452b9bb8324f2186a OP_CHECKSIG", + "version": 192 + } + ] + ] + }, + "options": {}, + "expected": { + "name": "p2mr", + "address": "bc1zej7kd3hhar76k3an5jr0t8fgyc47s4lnp4rh8uk4afrlwasuur3qzgewqq", + "output": "OP_2 ccbd66c6f7e8fdab47b3a486f59d28262be857f30d4773f2d5ea47f7761ce0e2", + "hash": "ccbd66c6f7e8fdab47b3a486f59d28262be857f30d4773f2d5ea47f7761ce0e2", + "input": null, + "witness": null + } + }, + { + "description": "P2MR: three leaf alternative tree - BIP360 test vector", + "arguments": { + "scriptTree": [ + { + "output": "71981521ad9fc9036687364118fb6ccd2035b96a423c59c5430e98310a11abe2 OP_CHECKSIG", + "version": 192 + }, + [ + { + "output": "d5094d2dbe9b76e2c245a2b89b6006888952e2faa6a149ae318d69e520617748 OP_CHECKSIG", + "version": 192 + }, + { + "output": "c440b462ad48c7a77f94cd4532d8f2119dcebbd7c9764557e62726419b08ad4c OP_CHECKSIG", + "version": 192 + } + ] + ] + }, + "options": {}, + "expected": { + "name": "p2mr", + "address": "bc1z9a4jc5uhkmtgegvwpx3lq5tpv68layaf3pvz64wx7paatvejnhhsv52lcv", + "output": "OP_2 2f6b2c5397b6d68ca18e09a3f05161668ffe93a988582d55c6f07bd5b3329def", + "hash": "2f6b2c5397b6d68ca18e09a3f05161668ffe93a988582d55c6f07bd5b3329def", + "input": null, + "witness": null + } + }, + { + "description": "P2MR: witness from scriptTree and redeem (single leaf)", + "arguments": { + "scriptTree": { + "output": "b617298552a72ade070667e86ca63b8f5789a9fe8731ef91202a91c9f3459007 OP_CHECKSIG", + "version": 192 + }, + "redeem": { + "output": "b617298552a72ade070667e86ca63b8f5789a9fe8731ef91202a91c9f3459007 OP_CHECKSIG", + "redeemVersion": 192 + } + }, + "options": {}, + "expected": { + "name": "p2mr", + "address": "bc1zc5jhzjnlf8pg4mdmhfuvqpvnr2quyd9j7mye5uly6psg9twghu4ssr0v9k", + "output": "OP_2 c525714a7f49c28aedbbba78c005931a81c234b2f6c99a73e4d06082adc8bf2b", + "hash": "c525714a7f49c28aedbbba78c005931a81c234b2f6c99a73e4d06082adc8bf2b", + "input": null, + "witness": [ + "20b617298552a72ade070667e86ca63b8f5789a9fe8731ef91202a91c9f3459007ac", + "c1" + ] + } + }, + { + "description": "P2MR: witness two leaf same version - spend leaf 0 (BIP360)", + "arguments": { + "scriptTree": [ + { + "output": "44b178d64c32c4a05cc4f4d1407268f764c940d20ce97abfd44db5c3592b72fd OP_CHECKSIG", + "version": 192 + }, + { + "output": "546170726f6f74", + "version": 192 + } + ], + "redeem": { + "output": "44b178d64c32c4a05cc4f4d1407268f764c940d20ce97abfd44db5c3592b72fd OP_CHECKSIG", + "redeemVersion": 192 + } + }, + "options": {}, + "expected": { + "name": "p2mr", + "address": "bc1z4vtegvwz35ak37me39tl4a2f045u3q7xlv0pek0czjpas7avjrxqz20g2y", + "output": "OP_2 ab179431c28d3b68fb798957faf5497d69c883c6fb1e1cd9f81483d87bac90cc", + "hash": "ab179431c28d3b68fb798957faf5497d69c883c6fb1e1cd9f81483d87bac90cc", + "input": null, + "witness": [ + "2044b178d64c32c4a05cc4f4d1407268f764c940d20ce97abfd44db5c3592b72fdac", + "c12cb2b90daa543b544161530c925f285b06196940d6085ca9474d41dc3822c5cb" + ] + } + }, + { + "description": "P2MR: witness two leaf same version - spend leaf 1 (BIP360)", + "arguments": { + "scriptTree": [ + { + "output": "44b178d64c32c4a05cc4f4d1407268f764c940d20ce97abfd44db5c3592b72fd OP_CHECKSIG", + "version": 192 + }, + { + "output": "546170726f6f74", + "version": 192 + } + ], + "redeem": { + "output": "546170726f6f74", + "redeemVersion": 192 + } + }, + "options": {}, + "expected": { + "name": "p2mr", + "address": "bc1z4vtegvwz35ak37me39tl4a2f045u3q7xlv0pek0czjpas7avjrxqz20g2y", + "output": "OP_2 ab179431c28d3b68fb798957faf5497d69c883c6fb1e1cd9f81483d87bac90cc", + "hash": "ab179431c28d3b68fb798957faf5497d69c883c6fb1e1cd9f81483d87bac90cc", + "input": null, + "witness": [ + "07546170726f6f74", + "c164512fecdb5afa04f98839b50e6f0cb7b1e539bf6f205f67934083cdcc3c8d89" + ] + } + }, + { + "description": "P2MR: witness different version leaves - spend leaf 0 v=192 (BIP360)", + "arguments": { + "scriptTree": [ + { + "output": "387671353e273264c495656e27e39ba899ea8fee3bb69fb2a680e22093447d48 OP_CHECKSIG", + "version": 192 + }, + { + "output": "424950333431", + "version": 250 + } + ], + "redeem": { + "output": "387671353e273264c495656e27e39ba899ea8fee3bb69fb2a680e22093447d48 OP_CHECKSIG", + "redeemVersion": 192 + } + }, + "options": {}, + "expected": { + "name": "p2mr", + "address": "bc1zdskuzp4ts94h87ws0c7drmev3sf9dagewj8qsylyahfyqhf800hsam4d6e", + "output": "OP_2 6c2dc106ab816b73f9d07e3cd1ef2c8c1256f519748e0813e4edd2405d277bef", + "hash": "6c2dc106ab816b73f9d07e3cd1ef2c8c1256f519748e0813e4edd2405d277bef", + "input": null, + "witness": [ + "20387671353e273264c495656e27e39ba899ea8fee3bb69fb2a680e22093447d48ac", + "c1f224a923cd0021ab202ab139cc56802ddb92dcfc172b9212261a539df79a112a" + ] + } + }, + { + "description": "P2MR: witness different version leaves - spend leaf 1 v=250 (correct controlByte=0xFB, BIP360 test vector has bug: uses 0xC1)", + "arguments": { + "scriptTree": [ + { + "output": "387671353e273264c495656e27e39ba899ea8fee3bb69fb2a680e22093447d48 OP_CHECKSIG", + "version": 192 + }, + { + "output": "424950333431", + "version": 250 + } + ], + "redeem": { + "output": "424950333431", + "redeemVersion": 250 + } + }, + "options": {}, + "expected": { + "name": "p2mr", + "address": "bc1zdskuzp4ts94h87ws0c7drmev3sf9dagewj8qsylyahfyqhf800hsam4d6e", + "output": "OP_2 6c2dc106ab816b73f9d07e3cd1ef2c8c1256f519748e0813e4edd2405d277bef", + "hash": "6c2dc106ab816b73f9d07e3cd1ef2c8c1256f519748e0813e4edd2405d277bef", + "input": null, + "witness": [ + "06424950333431", + "fb8ad69ec7cf41c2a4001fd1f738bf1e505ce2277acdcaa63fe4765192497f47a7" + ] + } + }, + { + "description": "P2MR: witness lightning contract - spend leaf 0 (Alice) (BIP360)", + "arguments": { + "scriptTree": [ + { + "output": "9000 OP_NOP3 OP_DROP 9997a497d964fc1a62885b05a51166a65a90df00492c8d7cf61d6accf54803be OP_CHECKSIG", + "version": 192 + }, + { + "output": "OP_SHA256 6c60f404f8167a38fc70eaf8aa17ac351023bef86bcb9d1086a19afe95bd5333 OP_EQUALVERIFY 4edfcf9dfe6c0b5c83d1ab3f78d1b39a46ebac6798e08e19761f5ed89ec83c10 OP_CHECKSIG", + "version": 192 + } + ], + "redeem": { + "output": "9000 OP_NOP3 OP_DROP 9997a497d964fc1a62885b05a51166a65a90df00492c8d7cf61d6accf54803be OP_CHECKSIG", + "redeemVersion": 192 + } + }, + "options": {}, + "expected": { + "name": "p2mr", + "address": "bc1zg9jxlrqlu25kmkkh74r3h387uldfs72wlrz95n60c6j4n4svna4s4lhfhe", + "output": "OP_2 41646f8c1fe2a96ddad7f5471bc4fee7da98794ef8c45a4f4fc6a559d60c9f6b", + "hash": "41646f8c1fe2a96ddad7f5471bc4fee7da98794ef8c45a4f4fc6a559d60c9f6b", + "input": null, + "witness": [ + "029000b275209997a497d964fc1a62885b05a51166a65a90df00492c8d7cf61d6accf54803beac", + "c1632c8632b4f29c6291416e23135cf78ecb82e525788ea5ed6483e3c6ce943b42" + ] + } + }, + { + "description": "P2MR: witness lightning contract - spend leaf 1 (Bob) (BIP360)", + "arguments": { + "scriptTree": [ + { + "output": "9000 OP_NOP3 OP_DROP 9997a497d964fc1a62885b05a51166a65a90df00492c8d7cf61d6accf54803be OP_CHECKSIG", + "version": 192 + }, + { + "output": "OP_SHA256 6c60f404f8167a38fc70eaf8aa17ac351023bef86bcb9d1086a19afe95bd5333 OP_EQUALVERIFY 4edfcf9dfe6c0b5c83d1ab3f78d1b39a46ebac6798e08e19761f5ed89ec83c10 OP_CHECKSIG", + "version": 192 + } + ], + "redeem": { + "output": "OP_SHA256 6c60f404f8167a38fc70eaf8aa17ac351023bef86bcb9d1086a19afe95bd5333 OP_EQUALVERIFY 4edfcf9dfe6c0b5c83d1ab3f78d1b39a46ebac6798e08e19761f5ed89ec83c10 OP_CHECKSIG", + "redeemVersion": 192 + } + }, + "options": {}, + "expected": { + "name": "p2mr", + "address": "bc1zg9jxlrqlu25kmkkh74r3h387uldfs72wlrz95n60c6j4n4svna4s4lhfhe", + "output": "OP_2 41646f8c1fe2a96ddad7f5471bc4fee7da98794ef8c45a4f4fc6a559d60c9f6b", + "hash": "41646f8c1fe2a96ddad7f5471bc4fee7da98794ef8c45a4f4fc6a559d60c9f6b", + "input": null, + "witness": [ + "a8206c60f404f8167a38fc70eaf8aa17ac351023bef86bcb9d1086a19afe95bd533388204edfcf9dfe6c0b5c83d1ab3f78d1b39a46ebac6798e08e19761f5ed89ec83c10ac", + "c1c81451874bd9ebd4b6fd4bba1f84cdfb533c532365d22a0a702205ff658b17c9" + ] + } + }, + { + "description": "P2MR: witness three leaf complex - spend leaf 0 (BIP360)", + "arguments": { + "scriptTree": [ + { + "output": "72ea6adcf1d371dea8fba1035a09f3d24ed5a059799bae114084130ee5898e69 OP_CHECKSIG", + "version": 192 + }, + [ + { + "output": "2352d137f2f3ab38d1eaa976758873377fa5ebb817372c71e2c542313d4abda8 OP_CHECKSIG", + "version": 192 + }, + { + "output": "7337c0dd4253cb86f2c43a2351aadd82cccb12a172cd120452b9bb8324f2186a OP_CHECKSIG", + "version": 192 + } + ] + ], + "redeem": { + "output": "72ea6adcf1d371dea8fba1035a09f3d24ed5a059799bae114084130ee5898e69 OP_CHECKSIG", + "redeemVersion": 192 + } + }, + "options": {}, + "expected": { + "name": "p2mr", + "address": "bc1zej7kd3hhar76k3an5jr0t8fgyc47s4lnp4rh8uk4afrlwasuur3qzgewqq", + "output": "OP_2 ccbd66c6f7e8fdab47b3a486f59d28262be857f30d4773f2d5ea47f7761ce0e2", + "hash": "ccbd66c6f7e8fdab47b3a486f59d28262be857f30d4773f2d5ea47f7761ce0e2", + "input": null, + "witness": [ + "2072ea6adcf1d371dea8fba1035a09f3d24ed5a059799bae114084130ee5898e69ac", + "c1ffe578e9ea769027e4f5a3de40732f75a88a6353a09d767ddeb66accef85e553" + ] + } + }, + { + "description": "P2MR: witness three leaf complex - spend leaf 1 (BIP360)", + "arguments": { + "scriptTree": [ + { + "output": "72ea6adcf1d371dea8fba1035a09f3d24ed5a059799bae114084130ee5898e69 OP_CHECKSIG", + "version": 192 + }, + [ + { + "output": "2352d137f2f3ab38d1eaa976758873377fa5ebb817372c71e2c542313d4abda8 OP_CHECKSIG", + "version": 192 + }, + { + "output": "7337c0dd4253cb86f2c43a2351aadd82cccb12a172cd120452b9bb8324f2186a OP_CHECKSIG", + "version": 192 + } + ] + ], + "redeem": { + "output": "2352d137f2f3ab38d1eaa976758873377fa5ebb817372c71e2c542313d4abda8 OP_CHECKSIG", + "redeemVersion": 192 + } + }, + "options": {}, + "expected": { + "name": "p2mr", + "address": "bc1zej7kd3hhar76k3an5jr0t8fgyc47s4lnp4rh8uk4afrlwasuur3qzgewqq", + "output": "OP_2 ccbd66c6f7e8fdab47b3a486f59d28262be857f30d4773f2d5ea47f7761ce0e2", + "hash": "ccbd66c6f7e8fdab47b3a486f59d28262be857f30d4773f2d5ea47f7761ce0e2", + "input": null, + "witness": [ + "202352d137f2f3ab38d1eaa976758873377fa5ebb817372c71e2c542313d4abda8ac", + "c19e31407bffa15fefbf5090b149d53959ecdf3f62b1246780238c24501d5ceaf62645a02e0aac1fe69d69755733a9b7621b694bb5b5cde2bbfc94066ed62b9817" + ] + } + }, + { + "description": "P2MR: witness three leaf complex - spend leaf 2 (BIP360)", + "arguments": { + "scriptTree": [ + { + "output": "72ea6adcf1d371dea8fba1035a09f3d24ed5a059799bae114084130ee5898e69 OP_CHECKSIG", + "version": 192 + }, + [ + { + "output": "2352d137f2f3ab38d1eaa976758873377fa5ebb817372c71e2c542313d4abda8 OP_CHECKSIG", + "version": 192 + }, + { + "output": "7337c0dd4253cb86f2c43a2351aadd82cccb12a172cd120452b9bb8324f2186a OP_CHECKSIG", + "version": 192 + } + ] + ], + "redeem": { + "output": "7337c0dd4253cb86f2c43a2351aadd82cccb12a172cd120452b9bb8324f2186a OP_CHECKSIG", + "redeemVersion": 192 + } + }, + "options": {}, + "expected": { + "name": "p2mr", + "address": "bc1zej7kd3hhar76k3an5jr0t8fgyc47s4lnp4rh8uk4afrlwasuur3qzgewqq", + "output": "OP_2 ccbd66c6f7e8fdab47b3a486f59d28262be857f30d4773f2d5ea47f7761ce0e2", + "hash": "ccbd66c6f7e8fdab47b3a486f59d28262be857f30d4773f2d5ea47f7761ce0e2", + "input": null, + "witness": [ + "207337c0dd4253cb86f2c43a2351aadd82cccb12a172cd120452b9bb8324f2186aac", + "c1ba982a91d4fc552163cb1c0da03676102d5b7a014304c01f0c77b2b8e888de1c2645a02e0aac1fe69d69755733a9b7621b694bb5b5cde2bbfc94066ed62b9817" + ] + } + }, + { + "description": "P2MR: witness three leaf alternative - spend leaf 0 (BIP360)", + "arguments": { + "scriptTree": [ + { + "output": "71981521ad9fc9036687364118fb6ccd2035b96a423c59c5430e98310a11abe2 OP_CHECKSIG", + "version": 192 + }, + [ + { + "output": "d5094d2dbe9b76e2c245a2b89b6006888952e2faa6a149ae318d69e520617748 OP_CHECKSIG", + "version": 192 + }, + { + "output": "c440b462ad48c7a77f94cd4532d8f2119dcebbd7c9764557e62726419b08ad4c OP_CHECKSIG", + "version": 192 + } + ] + ], + "redeem": { + "output": "71981521ad9fc9036687364118fb6ccd2035b96a423c59c5430e98310a11abe2 OP_CHECKSIG", + "redeemVersion": 192 + } + }, + "options": {}, + "expected": { + "name": "p2mr", + "address": "bc1z9a4jc5uhkmtgegvwpx3lq5tpv68layaf3pvz64wx7paatvejnhhsv52lcv", + "output": "OP_2 2f6b2c5397b6d68ca18e09a3f05161668ffe93a988582d55c6f07bd5b3329def", + "hash": "2f6b2c5397b6d68ca18e09a3f05161668ffe93a988582d55c6f07bd5b3329def", + "input": null, + "witness": [ + "2071981521ad9fc9036687364118fb6ccd2035b96a423c59c5430e98310a11abe2ac", + "c13cd369a528b326bc9d2133cbd2ac21451acb31681a410434672c8e34fe757e91" + ] + } + }, + { + "description": "P2MR: witness three leaf alternative - spend leaf 1 (BIP360)", + "arguments": { + "scriptTree": [ + { + "output": "71981521ad9fc9036687364118fb6ccd2035b96a423c59c5430e98310a11abe2 OP_CHECKSIG", + "version": 192 + }, + [ + { + "output": "d5094d2dbe9b76e2c245a2b89b6006888952e2faa6a149ae318d69e520617748 OP_CHECKSIG", + "version": 192 + }, + { + "output": "c440b462ad48c7a77f94cd4532d8f2119dcebbd7c9764557e62726419b08ad4c OP_CHECKSIG", + "version": 192 + } + ] + ], + "redeem": { + "output": "d5094d2dbe9b76e2c245a2b89b6006888952e2faa6a149ae318d69e520617748 OP_CHECKSIG", + "redeemVersion": 192 + } + }, + "options": {}, + "expected": { + "name": "p2mr", + "address": "bc1z9a4jc5uhkmtgegvwpx3lq5tpv68layaf3pvz64wx7paatvejnhhsv52lcv", + "output": "OP_2 2f6b2c5397b6d68ca18e09a3f05161668ffe93a988582d55c6f07bd5b3329def", + "hash": "2f6b2c5397b6d68ca18e09a3f05161668ffe93a988582d55c6f07bd5b3329def", + "input": null, + "witness": [ + "20d5094d2dbe9b76e2c245a2b89b6006888952e2faa6a149ae318d69e520617748ac", + "c1d7485025fceb78b9ed667db36ed8b8dc7b1f0b307ac167fa516fe4352b9f4ef7f154e8e8e17c31d3462d7132589ed29353c6fafdb884c5a6e04ea938834f0d9d" + ] + } + }, + { + "description": "P2MR: witness three leaf alternative - spend leaf 2 (BIP360)", + "arguments": { + "scriptTree": [ + { + "output": "71981521ad9fc9036687364118fb6ccd2035b96a423c59c5430e98310a11abe2 OP_CHECKSIG", + "version": 192 + }, + [ + { + "output": "d5094d2dbe9b76e2c245a2b89b6006888952e2faa6a149ae318d69e520617748 OP_CHECKSIG", + "version": 192 + }, + { + "output": "c440b462ad48c7a77f94cd4532d8f2119dcebbd7c9764557e62726419b08ad4c OP_CHECKSIG", + "version": 192 + } + ] + ], + "redeem": { + "output": "c440b462ad48c7a77f94cd4532d8f2119dcebbd7c9764557e62726419b08ad4c OP_CHECKSIG", + "redeemVersion": 192 + } + }, + "options": {}, + "expected": { + "name": "p2mr", + "address": "bc1z9a4jc5uhkmtgegvwpx3lq5tpv68layaf3pvz64wx7paatvejnhhsv52lcv", + "output": "OP_2 2f6b2c5397b6d68ca18e09a3f05161668ffe93a988582d55c6f07bd5b3329def", + "hash": "2f6b2c5397b6d68ca18e09a3f05161668ffe93a988582d55c6f07bd5b3329def", + "input": null, + "witness": [ + "20c440b462ad48c7a77f94cd4532d8f2119dcebbd7c9764557e62726419b08ad4cac", + "c1737ed1fe30bc42b8022d717b44f0d93516617af64a64753b7a06bf16b26cd711f154e8e8e17c31d3462d7132589ed29353c6fafdb884c5a6e04ea938834f0d9d" + ] + } + } + ], + "invalid": [ + { + "exception": "Not enough data", + "arguments": {} + }, + { + "exception": "Invalid address version", + "description": "P2TR address used as P2MR", + "arguments": { + "address": "bc1p4dss6gkgq8003g0qyd5drwfqrztsadf2w2v3juz73gdz7cx82r6sj7lcqx" + } + }, + { + "exception": "Output is invalid", + "description": "P2TR output used as P2MR (wrong OP code)", + "arguments": { + "output": "OP_1 ab610d22c801def8a1e02368d1b92018970eb52a729919705e8a1a2f60c750f5" + } + }, + { + "exception": "P2MR does not support key-path spending", + "description": "single element witness (key-path spend attempt)", + "arguments": { + "hash": "c525714a7f49c28aedbbba78c005931a81c234b2f6c99a73e4d06082adc8bf2b", + "witness": [ + "a251221c339a7129dd0b769698aca40d8d9da9570ab796a1820b91ab7dbf5acbea21c88ba8f1e9308a21729baf080734beaf97023882d972f75e380d480fd704" + ] + } + }, + { + "exception": "Hash mismatch", + "description": "Hash mismatch between scriptTree and hash", + "arguments": { + "scriptTree": { + "output": "b617298552a72ade070667e86ca63b8f5789a9fe8731ef91202a91c9f3459007 OP_CHECKSIG", + "version": 192 + }, + "hash": "0000000000000000000000000000000000000000000000000000000000000000" + } + }, + { + "exception": "Redeem script not in tree", + "description": "Redeem script not in tree", + "arguments": { + "scriptTree": { + "output": "b617298552a72ade070667e86ca63b8f5789a9fe8731ef91202a91c9f3459007 OP_CHECKSIG", + "version": 192 + }, + "redeem": { + "output": "0000000000000000000000000000000000000000000000000000000000000000 OP_CHECKSIG", + "redeemVersion": 192 + } + } + } + ] +} diff --git a/test/payments.spec.ts b/test/payments.spec.ts index d7128c0ab7..1ff0c06b02 100644 --- a/test/payments.spec.ts +++ b/test/payments.spec.ts @@ -14,6 +14,7 @@ const { p2wpkh, p2wsh, p2tr, + p2mr, } = payments; import embedFixtures from './fixtures/embed.json'; @@ -24,6 +25,7 @@ import p2shFixtures from './fixtures/p2sh.json'; import p2wpkhFixtures from './fixtures/p2wpkh.json'; import p2wshFixtures from './fixtures/p2wsh.json'; import p2trFixtures from './fixtures/p2tr.json'; +import p2mrFixtures from './fixtures/p2mr.json'; let testSuite: { paymentName: string; @@ -70,6 +72,11 @@ let testSuite: { fixtures: p2trFixtures, payment: p2tr, }, + { + paymentName: 'p2mr', + fixtures: p2mrFixtures, + payment: p2mr, + }, ]; testSuite.forEach(p => { From e1c0ea807069ba017971f622be7e82acfd9efe84 Mon Sep 17 00:00:00 2001 From: jasonandjay <342690199@qq.com> Date: Sun, 15 Feb 2026 10:53:27 +0800 Subject: [PATCH 3/4] test: add P2MR integration tests for full PSBT workflow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add 9 integration tests covering the complete P2MR pipeline: - Address generation and validation (mainnet bc1z, regtest bcrt1z) - Address ↔ output script roundtrip via bitcoin.address - Single-leaf script-path spend (OP_CHECKSIG) with PSBT sign/finalize - Two-leaf tree spending each leaf independently (+ wrong-key rejection) - Three-leaf tree spending a depth-2 leaf - PSBT serialization/deserialization roundtrip - Key-path spend rejection enforcement - P2MR vs P2TR structural comparison (no internalPubkey) Note: P2MR transactions cannot be broadcast until BIP 360 activates, so these tests verify the full PSBT workflow locally without regtest. --- test/integration/p2mr.spec.ts | 406 ++++++++++++++++++++++++++++++++++ 1 file changed, 406 insertions(+) create mode 100644 test/integration/p2mr.spec.ts diff --git a/test/integration/p2mr.spec.ts b/test/integration/p2mr.spec.ts new file mode 100644 index 0000000000..b196f1e248 --- /dev/null +++ b/test/integration/p2mr.spec.ts @@ -0,0 +1,406 @@ +import * as assert from 'assert'; +import BIP32Factory from 'bip32'; +import * as ecc from 'tiny-secp256k1'; +import { describe, it } from 'mocha'; +import * as bitcoin from 'bitcoinjs-lib'; +import { Taptree } from 'bitcoinjs-lib/src/types'; +import { LEAF_VERSION_TAPSCRIPT } from 'bitcoinjs-lib/src/payments/bip341'; +import { toXOnly } from 'bitcoinjs-lib/src/psbt/bip371'; +import * as tools from 'uint8array-tools'; +import { randomBytes } from 'crypto'; + +bitcoin.initEccLib(ecc); +const bip32 = BIP32Factory(ecc); +const rng = (size: number) => randomBytes(size); +const regtest = bitcoin.networks.regtest; + +describe('bitcoinjs-lib (Pay-to-Merkle-Root - BIP 360)', () => { + it('can generate a P2MR mainnet address (bc1z prefix) from BIP360 test vector', () => { + const scriptTree: Taptree = { + output: bitcoin.script.fromASM( + 'b617298552a72ade070667e86ca63b8f5789a9fe8731ef91202a91c9f3459007 OP_CHECKSIG', + ), + }; + + const { address, output, hash } = bitcoin.payments.p2mr({ scriptTree }); + + assert.ok(address); + assert.ok(address.startsWith('bc1z')); + assert.strictEqual( + address, + 'bc1zc5jhzjnlf8pg4mdmhfuvqpvnr2quyd9j7mye5uly6psg9twghu4ssr0v9k', + ); + assert.ok(output); + assert.strictEqual(output.length, 34); + assert.strictEqual(output[0], bitcoin.opcodes.OP_2); // SegWit version 2 + assert.ok(hash); + assert.strictEqual( + tools.toHex(hash), + 'c525714a7f49c28aedbbba78c005931a81c234b2f6c99a73e4d06082adc8bf2b', + ); + }); + + it('can generate a P2MR regtest address (bcrt1z prefix)', () => { + const leafKey = bip32.fromSeed(rng(64), regtest); + const leafPubkey = toXOnly(leafKey.publicKey); + + const scriptTree: Taptree = { + output: bitcoin.script.fromASM(`${tools.toHex(leafPubkey)} OP_CHECKSIG`), + }; + + const { address, output } = bitcoin.payments.p2mr({ + scriptTree, + network: regtest, + }); + + assert.ok(address); + assert.ok(address.startsWith('bcrt1z')); + assert.ok(output); + assert.strictEqual(output.length, 34); + assert.strictEqual(output[0], bitcoin.opcodes.OP_2); + }); + + it('can roundtrip P2MR address ↔ output via bitcoin.address', () => { + const { address } = bitcoin.payments.p2mr({ + scriptTree: { + output: bitcoin.script.fromASM( + 'b617298552a72ade070667e86ca63b8f5789a9fe8731ef91202a91c9f3459007 OP_CHECKSIG', + ), + }, + }); + assert.ok(address); + + const outputScript = bitcoin.address.toOutputScript(address); + const recoveredAddress = bitcoin.address.fromOutputScript(outputScript); + assert.strictEqual(recoveredAddress, address); + }); + + it('can create and sign a P2MR single-leaf script-path spend (OP_CHECKSIG)', () => { + const leafKey = bip32.fromSeed(rng(64), regtest); + const leafPubkey = toXOnly(leafKey.publicKey); + + const leafScript = bitcoin.script.fromASM( + `${tools.toHex(leafPubkey)} OP_CHECKSIG`, + ); + + const scriptTree: Taptree = { output: leafScript }; + const redeem = { + output: leafScript, + redeemVersion: LEAF_VERSION_TAPSCRIPT, + }; + + const { output, witness, address } = bitcoin.payments.p2mr({ + scriptTree, + redeem, + network: regtest, + }); + + assert.ok(output && witness && address); + assert.strictEqual(witness.length, 2); // [script, controlBlock] + + const controlBlock = witness[witness.length - 1]; + // Single leaf: control block is just 1 byte (no Merkle path) + assert.strictEqual(controlBlock.length, 1); + assert.strictEqual(controlBlock[0] & 0xfe, LEAF_VERSION_TAPSCRIPT); + assert.strictEqual(controlBlock[0] & 1, 1); // parity bit always 1 for P2MR + + // Build PSBT + const amount = 42e4; + const sendAmount = amount - 1e4; + + const psbt = new bitcoin.Psbt({ network: regtest }); + psbt.addInput({ + hash: Buffer.alloc(32, 1), // mock txid + index: 0, + witnessUtxo: { value: BigInt(amount), script: output }, + }); + psbt.updateInput(0, { + tapLeafScript: [ + { + leafVersion: redeem.redeemVersion, + script: redeem.output, + controlBlock, + }, + ], + }); + psbt.addOutput({ value: BigInt(sendAmount), address }); + + // Sign → Finalize → Extract + psbt.signInput(0, leafKey); + psbt.finalizeInput(0); + const tx = psbt.extractTransaction(); + + // Verify witness: [schnorr_sig, script, controlBlock] + const wit = tx.ins[0].witness; + assert.strictEqual(wit.length, 3); + assert.strictEqual(wit[0].length, 64); // Schnorr signature + assert.ok(tools.compare(wit[1], leafScript) === 0); + assert.ok(tools.compare(wit[2], controlBlock) === 0); + }); + + it('can create and sign a P2MR two-leaf tree, spending each leaf independently', () => { + const keys = [ + bip32.fromSeed(rng(64), regtest), + bip32.fromSeed(rng(64), regtest), + ]; + const leafScripts = keys.map(k => + bitcoin.script.fromASM( + `${tools.toHex(toXOnly(k.publicKey))} OP_CHECKSIG`, + ), + ); + + const scriptTree: Taptree = [ + { output: leafScripts[0] }, + { output: leafScripts[1] }, + ]; + + for (let i = 0; i < 2; i++) { + const redeem = { + output: leafScripts[i], + redeemVersion: LEAF_VERSION_TAPSCRIPT, + }; + + const { output, witness, address } = bitcoin.payments.p2mr({ + scriptTree, + redeem, + network: regtest, + }); + assert.ok(output && witness && address); + + const controlBlock = witness[witness.length - 1]; + // Two-leaf tree: controlByte(1) + sibling_hash(32) = 33 bytes + assert.strictEqual(controlBlock.length, 33); + + const amount = 42e4; + const sendAmount = amount - 1e4; + + const psbt = new bitcoin.Psbt({ network: regtest }); + psbt.addInput({ + hash: Buffer.alloc(32, i + 2), + index: 0, + witnessUtxo: { value: BigInt(amount), script: output }, + }); + psbt.updateInput(0, { + tapLeafScript: [ + { + leafVersion: redeem.redeemVersion, + script: redeem.output, + controlBlock, + }, + ], + }); + psbt.addOutput({ value: BigInt(sendAmount), address }); + + // Correct key signs successfully + psbt.signInput(0, keys[i]); + psbt.finalizeInput(0); + + const tx = psbt.extractTransaction(); + const wit = tx.ins[0].witness; + assert.strictEqual(wit.length, 3); + assert.strictEqual(wit[0].length, 64); + assert.ok(tools.compare(wit[1], leafScripts[i]) === 0); + assert.ok(tools.compare(wit[2], controlBlock) === 0); + + // Wrong key should fail to sign + const wrongKey = keys[(i + 1) % 2]; + const psbt2 = new bitcoin.Psbt({ network: regtest }); + psbt2.addInput({ + hash: Buffer.alloc(32, i + 2), + index: 0, + witnessUtxo: { value: BigInt(amount), script: output }, + }); + psbt2.updateInput(0, { + tapLeafScript: [ + { + leafVersion: redeem.redeemVersion, + script: redeem.output, + controlBlock, + }, + ], + }); + psbt2.addOutput({ value: BigInt(sendAmount), address }); + + assert.throws(() => { + psbt2.signInput(0, wrongKey); + }, /Can not sign for input/); + } + }); + + it('can create and sign a P2MR three-leaf tree (spend depth-2 leaf)', () => { + const keys = [ + bip32.fromSeed(rng(64), regtest), + bip32.fromSeed(rng(64), regtest), + bip32.fromSeed(rng(64), regtest), + ]; + const leafScripts = keys.map(k => + bitcoin.script.fromASM( + `${tools.toHex(toXOnly(k.publicKey))} OP_CHECKSIG`, + ), + ); + + // Tree structure: [leaf0, [leaf1, leaf2]] + const scriptTree: Taptree = [ + { output: leafScripts[0] }, + [{ output: leafScripts[1] }, { output: leafScripts[2] }], + ]; + + // Spend leaf 2 (deepest right) + const redeem = { + output: leafScripts[2], + redeemVersion: LEAF_VERSION_TAPSCRIPT, + }; + + const { output, witness, address } = bitcoin.payments.p2mr({ + scriptTree, + redeem, + network: regtest, + }); + assert.ok(output && witness && address); + + const controlBlock = witness[witness.length - 1]; + // Depth-2 leaf: controlByte(1) + 2 * 32 = 65 bytes + assert.strictEqual(controlBlock.length, 65); + + const amount = 42e4; + const sendAmount = amount - 1e4; + + const psbt = new bitcoin.Psbt({ network: regtest }); + psbt.addInput({ + hash: Buffer.alloc(32, 5), + index: 0, + witnessUtxo: { value: BigInt(amount), script: output }, + }); + psbt.updateInput(0, { + tapLeafScript: [ + { + leafVersion: redeem.redeemVersion, + script: redeem.output, + controlBlock, + }, + ], + }); + psbt.addOutput({ value: BigInt(sendAmount), address }); + + psbt.signInput(0, keys[2]); + psbt.finalizeInput(0); + + const tx = psbt.extractTransaction(); + const wit = tx.ins[0].witness; + assert.strictEqual(wit.length, 3); + assert.strictEqual(wit[0].length, 64); + assert.ok(tools.compare(wit[1], leafScripts[2]) === 0); + assert.ok(tools.compare(wit[2], controlBlock) === 0); + }); + + it('can serialize and deserialize a P2MR PSBT', () => { + const leafKey = bip32.fromSeed(rng(64), regtest); + const leafPubkey = toXOnly(leafKey.publicKey); + const leafScript = bitcoin.script.fromASM( + `${tools.toHex(leafPubkey)} OP_CHECKSIG`, + ); + + const scriptTree: Taptree = { output: leafScript }; + const redeem = { + output: leafScript, + redeemVersion: LEAF_VERSION_TAPSCRIPT, + }; + + const { output, witness, address } = bitcoin.payments.p2mr({ + scriptTree, + redeem, + network: regtest, + }); + assert.ok(output && witness && address); + + const controlBlock = witness[witness.length - 1]; + const amount = 42e4; + const sendAmount = amount - 1e4; + + // Build and sign the PSBT + const psbt = new bitcoin.Psbt({ network: regtest }); + psbt.addInput({ + hash: Buffer.alloc(32, 6), + index: 0, + witnessUtxo: { value: BigInt(amount), script: output }, + }); + psbt.updateInput(0, { + tapLeafScript: [ + { + leafVersion: redeem.redeemVersion, + script: redeem.output, + controlBlock, + }, + ], + }); + psbt.addOutput({ value: BigInt(sendAmount), address }); + + psbt.signInput(0, leafKey); + + // Serialize → Deserialize roundtrip + const psbtHex = psbt.toHex(); + const psbt2 = bitcoin.Psbt.fromHex(psbtHex, { network: regtest }); + + // Finalize from the deserialized PSBT + psbt2.finalizeInput(0); + const tx = psbt2.extractTransaction(); + + const wit = tx.ins[0].witness; + assert.strictEqual(wit.length, 3); + assert.strictEqual(wit[0].length, 64); + assert.ok(tools.compare(wit[1], leafScript) === 0); + assert.ok(tools.compare(wit[2], controlBlock) === 0); + }); + + it('rejects key-path spending for P2MR', () => { + const leafKey = bip32.fromSeed(rng(64), regtest); + const leafScript = bitcoin.script.fromASM( + `${tools.toHex(toXOnly(leafKey.publicKey))} OP_CHECKSIG`, + ); + + const { output } = bitcoin.payments.p2mr({ + scriptTree: { output: leafScript }, + network: regtest, + }); + assert.ok(output); + + // A single-element witness means key-path spend attempt → must fail + assert.throws(() => { + bitcoin.payments.p2mr({ + output, + witness: [Buffer.alloc(64)], + network: regtest, + }); + }, /P2MR does not support key-path spending/); + }); + + it('P2MR has no internalPubkey (unlike P2TR)', () => { + const leafKey = bip32.fromSeed(rng(64), regtest); + const leafScript = bitcoin.script.fromASM( + `${tools.toHex(toXOnly(leafKey.publicKey))} OP_CHECKSIG`, + ); + + const scriptTree: Taptree = { output: leafScript }; + + const p2mr = bitcoin.payments.p2mr({ scriptTree, network: regtest }); + const p2tr = bitcoin.payments.p2tr({ + internalPubkey: toXOnly(leafKey.publicKey), + scriptTree, + network: regtest, + }); + + // P2MR has no internalPubkey / pubkey + assert.strictEqual(p2mr.pubkey, undefined); + // P2TR has a pubkey (tweaked output key) + assert.ok(p2tr.pubkey); + + // Both have output and address, but they differ + assert.ok(p2mr.output); + assert.ok(p2tr.output); + assert.ok(tools.compare(p2mr.output, p2tr.output!) !== 0); + + // P2MR address starts with bc1z (witness v2), P2TR with bc1p (witness v1) + assert.ok(p2mr.address!.startsWith('bcrt1z')); + assert.ok(p2tr.address!.startsWith('bcrt1p')); + }); +}); From e079eb4b970d4d84571f4b54a8b2d3f070321d9b Mon Sep 17 00:00:00 2001 From: jasonandjay <342690199@qq.com> Date: Sun, 15 Feb 2026 10:58:18 +0800 Subject: [PATCH 4/4] docs: add P2MR (BIP 360) examples to README Link to the P2MR integration tests from the Examples section, following the existing pattern used for Taproot examples. --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index 73343a676f..e65fbdaad7 100644 --- a/README.md +++ b/README.md @@ -130,6 +130,8 @@ Some examples interact (via HTTPS) with a 3rd Party Blockchain Provider (3PBP). - [Create (and broadcast via 3PBP) a taproot script-path spend Transaction - OP_CHECKSIG](https://github.com/bitcoinjs/bitcoinjs-lib/blob/master/test/integration/taproot.spec.ts) - [Create (and broadcast via 3PBP) a taproot script-path spend Transaction - OP_CHECKSEQUENCEVERIFY](https://github.com/bitcoinjs/bitcoinjs-lib/blob/master/test/integration/taproot.spec.ts) - [Create (and broadcast via 3PBP) a taproot script-path spend Transaction - OP_CHECKSIGADD (3-of-3)](https://github.com/bitcoinjs/bitcoinjs-lib/blob/master/test/integration/taproot.spec.ts) +- [P2MR (BIP360) address generation and script-path spend](https://github.com/bitcoinjs/bitcoinjs-lib/blob/master/test/integration/p2mr.spec.ts) +- [P2MR multi-leaf script tree with PSBT signing](https://github.com/bitcoinjs/bitcoinjs-lib/blob/master/test/integration/p2mr.spec.ts) - [Generate a random address](https://github.com/bitcoinjs/bitcoinjs-lib/blob/master/test/integration/addresses.spec.ts) - [Import an address via WIF](https://github.com/bitcoinjs/bitcoinjs-lib/blob/master/test/integration/addresses.spec.ts) - [Generate a 2-of-3 P2SH multisig address](https://github.com/bitcoinjs/bitcoinjs-lib/blob/master/test/integration/addresses.spec.ts)