From 57801662cde01946e28a5a951ebb410c78807485 Mon Sep 17 00:00:00 2001 From: Boris Nagaev Date: Tue, 30 Jun 2026 01:29:40 -0500 Subject: [PATCH 01/17] consensus: enforce P2MR block script checks P2MR validation was wired into standard and mandatory policy flags, but block validation still built its script flag set from P2SH, witness, and taproot only. A native v2 32-byte witness program could therefore be spent arbitrarily inside a block because it continued down the unknown-witness-program success path. Include SCRIPT_VERIFY_P2MR in the block script flags so block connection evaluates the P2MR control block, Merkle root, and tapscript rules instead of treating these outputs as anyone-can-spend witness upgrades. --- src/validation.cpp | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/validation.cpp b/src/validation.cpp index 90e70834bcbb..a81410dd34d6 100644 --- a/src/validation.cpp +++ b/src/validation.cpp @@ -2360,9 +2360,9 @@ static unsigned int GetBlockScriptFlags(const CBlockIndex& block_index, const Ch // mainnet and testnet). // Similarly, only one historical block violated the TAPROOT rules on // mainnet. - // For simplicity, always leave P2SH+WITNESS+TAPROOT on except for the two + // For simplicity, always leave P2SH+WITNESS+TAPROOT+P2MR on except for the two // violating blocks. - uint32_t flags{SCRIPT_VERIFY_P2SH | SCRIPT_VERIFY_WITNESS | SCRIPT_VERIFY_TAPROOT}; + uint32_t flags{SCRIPT_VERIFY_P2SH | SCRIPT_VERIFY_WITNESS | SCRIPT_VERIFY_TAPROOT | SCRIPT_VERIFY_P2MR}; const auto it{consensusparams.script_flag_exceptions.find(*Assert(block_index.phashBlock))}; if (it != consensusparams.script_flag_exceptions.end()) { flags = it->second; From 3520c5318887eeb3f6c9958c6073e1359a644c97 Mon Sep 17 00:00:00 2001 From: Boris Nagaev Date: Tue, 30 Jun 2026 01:30:34 -0500 Subject: [PATCH 02/17] test: reject invalid P2MR spends in blocks A native v2 32-byte output must not remain spendable through the generic unknown-witness-program path once P2MR validation is active. This regression test mines such an output, matures it, and submits a block containing a one-element witness spend that has no P2MR script path. With block script flags enforcing P2MR, submitblock fails with block-script-verify-flag-failed. If the consensus flag wiring is reverted, the same block connects because the spend is treated as an unknown witness program. --- test/functional/feature_p2mr.py | 86 +++++++++++++++++++++++++++++++++ test/functional/test_runner.py | 1 + 2 files changed, 87 insertions(+) create mode 100644 test/functional/feature_p2mr.py diff --git a/test/functional/feature_p2mr.py b/test/functional/feature_p2mr.py new file mode 100644 index 000000000000..c3e1b3efb358 --- /dev/null +++ b/test/functional/feature_p2mr.py @@ -0,0 +1,86 @@ +#!/usr/bin/env python3 +# Copyright (c) 2026 The Bitcoin Core developers +# Distributed under the MIT software license, see the accompanying +# file COPYING or http://www.opensource.org/licenses/mit-license.php. +"""Test Pay-to-Merkle-Root consensus behavior.""" + +from test_framework.blocktools import ( + COINBASE_MATURITY, + add_witness_commitment, + create_block, + create_coinbase, +) +from test_framework.messages import ( + COutPoint, + CTransaction, + CTxIn, + CTxInWitness, + CTxOut, + COIN, + SEQUENCE_FINAL, +) +from test_framework.script import ( + CScript, + OP_2, + OP_TRUE, +) +from test_framework.test_framework import BitcoinTestFramework +from test_framework.util import assert_equal + + +class P2MRTest(BitcoinTestFramework): + def set_test_params(self): + self.num_nodes = 1 + self.setup_clean_chain = True + self.extra_args = [["-acceptnonstdtxn=1"]] + + def _mine_block(self, txs=None, *, script_pubkey=None): + node = self.nodes[0] + height = node.getblockcount() + 1 + block = create_block( + hashprev=int(node.getbestblockhash(), 16), + coinbase=create_coinbase(height, script_pubkey=script_pubkey), + ntime=self._next_block_time(), + txlist=txs or [], + ) + if txs: + add_witness_commitment(block) + block.solve() + assert_equal(node.submitblock(block.serialize().hex()), None) + return block + + def _next_block_time(self): + return self.nodes[0].getblockheader(self.nodes[0].getbestblockhash())["mediantime"] + 1 + + def run_test(self): + node = self.nodes[0] + + self.log.info("Mine a native v2 32-byte output") + p2mr_program = b"\x11" * 32 + p2mr_script = CScript([OP_2, p2mr_program]) + funding_block = self._mine_block(script_pubkey=p2mr_script) + funding_tx = funding_block.vtx[0] + + self.generate(node, COINBASE_MATURITY) + + self.log.info("Reject a block that spends P2MR through the unknown-witness path") + spend = CTransaction() + spend.vin.append(CTxIn(COutPoint(funding_tx.txid_int, 0), b"", SEQUENCE_FINAL)) + spend.vout.append(CTxOut(49 * COIN, CScript([OP_TRUE]))) + spend.wit.vtxinwit = [CTxInWitness()] + spend.wit.vtxinwit[0].scriptWitness.stack = [b"not a p2mr script path"] + + height = node.getblockcount() + 1 + bad_block = create_block( + hashprev=int(node.getbestblockhash(), 16), + coinbase=create_coinbase(height), + ntime=self._next_block_time(), + txlist=[spend], + ) + add_witness_commitment(bad_block) + bad_block.solve() + assert_equal(node.submitblock(bad_block.serialize().hex()), "mandatory-script-verify-flag-failed (Witness program hash mismatch)") + + +if __name__ == "__main__": + P2MRTest(__file__).main() diff --git a/test/functional/test_runner.py b/test/functional/test_runner.py index a32e661bb300..541adf259183 100755 --- a/test/functional/test_runner.py +++ b/test/functional/test_runner.py @@ -105,6 +105,7 @@ 'feature_assumeutxo.py', 'mempool_updatefromblock.py', 'mempool_persist.py', + 'feature_p2mr.py', # vv Tests less than 60s vv 'rpc_p2qrh.py', 'rpc_psbt.py', From 527508580b6c8c003a6dfc7a3e6f2e7486aad428 Mon Sep 17 00:00:00 2001 From: Boris Nagaev Date: Tue, 30 Jun 2026 01:35:29 -0500 Subject: [PATCH 03/17] descriptors: emit v2 outputs for tmr BIP-360 assigns Pay-to-Merkle-Root to native witness version 2 with a 32-byte Merkle root program. The tmr() descriptor builder was emitting OP_3, so descriptors produced v3/bech32m outputs that decoded as witness_unknown and never exercised the P2MR validation path. Emit OP_2 for tmr() outputs so descriptor-derived scripts match the interpreter, address encoder, and solver's WITNESS_V2_P2MR type. --- src/script/descriptor.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/script/descriptor.cpp b/src/script/descriptor.cpp index 7e6588b216d0..96fe35af1925 100644 --- a/src/script/descriptor.cpp +++ b/src/script/descriptor.cpp @@ -1631,7 +1631,7 @@ class P2MRDescriptor final : public DescriptorImpl uint256 merkle_root = builder.GetSpendData().merkle_root; CScript output_script; - output_script << OP_3 << ToByteVector(merkle_root); + output_script << OP_2 << ToByteVector(merkle_root); return {output_script}; } From c80c51ac53f26ef0ef13ed8873ec3b6775557ff4 Mon Sep 17 00:00:00 2001 From: Boris Nagaev Date: Tue, 30 Jun 2026 01:36:41 -0500 Subject: [PATCH 04/17] test: cover tmr descriptor output version tmr() descriptors must materialize native witness v2 P2MR outputs. Without coverage, the descriptor code could emit OP_3 and still parse successfully while producing addresses that decode as witness_unknown. Add a descriptor regression that expands tmr(pk(...)), asserts the script starts with OP_2 OP_PUSHBYTES_32, checks Solver returns WITNESS_V2_P2MR, and confirms the encoded address uses the bc1z witness-v2 prefix. --- src/test/descriptor_tests.cpp | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/src/test/descriptor_tests.cpp b/src/test/descriptor_tests.cpp index 558045d74382..2ea7121b8494 100644 --- a/src/test/descriptor_tests.cpp +++ b/src/test/descriptor_tests.cpp @@ -2,9 +2,12 @@ // Distributed under the MIT software license, see the accompanying // file COPYING or http://www.opensource.org/licenses/mit-license.php. +#include +#include #include #include