From bb28ed16251338181a0252964f3a8a252a4446d3 Mon Sep 17 00:00:00 2001 From: LionThunderFingers <42521162+LionThunderFingers@users.noreply.github.com> Date: Sat, 10 Oct 2026 17:30:56 +0200 Subject: [PATCH 1/5] policy: count tapscript pubkeys no signature can prove as datacarrier bytes The rule only recognised a key in a 33 or 65 byte push, so the same payload written as tapscript went free: a 1-of-15 costs 132 datacarrier bytes under P2WSH and nothing at all under a taproot leaf, for 32 bytes of publishable space per key rather than 31. A 32 byte push is not a key on sight, since nothing separates an x-only key from a hash until something spends it. Count it where a checksig-family opcode claims it instead, which leaves the preimage of a hashlock alone. OP_CHECKSIGADD is how tapscript writes an m-of-n, so credit its canonical shape as OP_CHECKMULTISIG's is already credited: a run of OP_CHECKSIG ( OP_CHECKSIGADD)* OP_NUMEQUAL credits k, whatever size its keys are. Without it every honest tapscript multisig past eleven keys would be charged for the keys it does not spend, which is the opposite of the point: a 20-of-20 would cost 297 bytes. The OP_CHECKSIG that opens the run has credited one of the k already, so only the remaining k-1 are added there. Padding the run, reordering it or naming a k it cannot satisfy forfeits the credit rather than earning one, so working around the rule raises the charge instead of lowering it. An opcode that leaves the stack as it found it does not hide the key beneath it from the opcode that spends it, so it does not buy the count off either. --- src/script/script.cpp | 85 ++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 84 insertions(+), 1 deletion(-) diff --git a/src/script/script.cpp b/src/script/script.cpp index 44da23c8ba16..ac2c428a1741 100644 --- a/src/script/script.cpp +++ b/src/script/script.cpp @@ -375,12 +375,44 @@ static int MultisigCount(const opcodetype opcode, const std::vector& push_data) +{ + if (const int small{SmallIntValue(opcode)}; small > 0) return small; + if (opcode != 1 && opcode != 2) return -1; + if (push_data.size() != size_t{opcode}) return -1; + // A minimal push carries no redundant high byte, and the sign bit is never set here. + if (push_data.back() == 0 || (push_data.back() & 0x80)) return -1; + int value{push_data[0]}; + if (push_data.size() == 2) value |= int{push_data[1]} << 8; + if (value <= 16 || value > int{MAX_PUBKEYS_PER_MULTI_A}) return -1; + return value; +} + static bool IsPubkeyPush(const opcodetype opcode, const std::vector& push_data) { if (opcode > OP_PUSHDATA4) return false; return push_data.size() == CPubKey::COMPRESSED_SIZE || push_data.size() == CPubKey::SIZE; } +/** A 32 byte push, which is an x-only key where a checksig-family opcode claims it as one. */ +static bool IsXOnlyPush(const opcodetype opcode, const std::vector& push_data) +{ + return opcode <= OP_PUSHDATA4 && push_data.size() == XOnlyPubKey::size(); +} + +/** Opcodes that leave the stack as they found it, so the key below them is still on top. */ +static bool IsStackNeutralNop(const opcodetype opcode) +{ + return opcode == OP_NOP || (opcode >= OP_NOP1 && opcode <= OP_NOP10); +} + /** Mirror of the push run the caller counts when it is balanced by a drop. */ static bool IsRunPush(const opcodetype opcode) { @@ -423,9 +455,15 @@ static size_t UnprovenPubkeyBytes(const CScript& script, const CScriptWitness* w size_t run_keys{0}; // Keys in the current push run, which the caller counts whole if a drop balances it. size_t push_run_keys{0}; - int run_opened_by{-1}, last_count{-1}; + // The current run of a tapscript k-of-n, which names its keys one at a time as + // OP_CHECKSIG ( OP_CHECKSIGADD)* OP_NUMEQUAL. Only that shape credits k. + size_t multi_a_keys{0}; + int run_opened_by{-1}, last_count{-1}, last_a_count{-1}; unsigned int inside_noop{0}; bool last_is_push{false}; + // Whether the item before this one was a key a checksig-family opcode can still claim, and + // whether it was the 32 byte kind that is only a key because one does. + bool last_is_key{false}, last_is_xonly{false}; opcodetype opcode{OP_INVALIDOPCODE}, last_opcode{OP_INVALIDOPCODE}; std::vector push_data; @@ -433,7 +471,10 @@ static size_t UnprovenPubkeyBytes(const CScript& script, const CScriptWitness* w // The caller counts an unparsable script whole, and the iterator may not have advanced. if (!script.GetOp(it, opcode, push_data)) return 0; const bool is_pubkey{IsPubkeyPush(opcode, push_data)}; + const bool is_xonly{IsXOnlyPush(opcode, push_data)}; const int count{MultisigCount(opcode, push_data)}; + const int a_count{MultiACount(opcode, push_data)}; + bool is_key{false}; if (inside_noop) { // A closed OP_FALSE OP_IF envelope is counted whole by the caller, so skip over it. @@ -449,34 +490,76 @@ static size_t UnprovenPubkeyBytes(const CScript& script, const CScriptWitness* w ++run_keys; ++push_run_keys; ++pubkeys; + is_key = true; + } else if (is_xonly) { + // Nothing tells an x-only key from a hash but the opcode that spends it, so this + // one is counted where a checksig-family opcode claims it rather than here. It + // stays out of the push run for the same reason: a drop never reaches it. + run_keys = 0; + is_key = true; } else if ((opcode == OP_DROP || opcode == OP_2DROP) && last_is_push) { // The caller counts the whole run as dropped data, so do not charge it again. pubkeys -= push_run_keys; run_keys = 0; + multi_a_keys = 0; } else if (opcode == OP_CHECKSIG || opcode == OP_CHECKSIGVERIFY) { + if (last_is_xonly) ++pubkeys; ++provable; run_keys = 0; + // OP_CHECKSIG opens a tapscript k-of-n; anything else there ends one. + multi_a_keys = (last_is_key && opcode == OP_CHECKSIG) ? 1 : 0; + } else if (opcode == OP_CHECKSIGADD) { + if (last_is_xonly) ++pubkeys; + // The run is credited the k it closes on, so a key earns nothing on its own here. + multi_a_keys = (multi_a_keys && last_is_key) ? multi_a_keys + 1 : 0; + run_keys = 0; + } else if ((opcode == OP_NUMEQUAL || opcode == OP_NUMEQUALVERIFY) && multi_a_keys) { + // Padding or reordering the keys forfeits the credit rather than earning one, and + // so does naming a k the run cannot satisfy. The OP_CHECKSIG that opened the run + // has credited one of the k already. + if (last_a_count > 0 && size_t(last_a_count) <= multi_a_keys) { + provable += size_t(last_a_count) - 1; + } + multi_a_keys = 0; + run_keys = 0; } else if (opcode == OP_CHECKMULTISIG || opcode == OP_CHECKMULTISIGVERIFY) { // Padding or reordering the keys forfeits the credit rather than earning one. if (run_opened_by > 0 && last_count > 0 && size_t(last_count) == run_keys) { provable += size_t(run_opened_by); } run_keys = 0; + multi_a_keys = 0; } else if (count < 0) { // A key count sits on both ends of a multisig, so it does not break the run. run_keys = 0; + if (a_count < 0) multi_a_keys = 0; } const bool is_push{IsRunPush(opcode)}; if (!is_push) push_run_keys = 0; + // An opcode that leaves the stack as it found it leaves the key below it on top, so it + // cannot hide that key from the opcode which spends it. It still breaks the run above: + // padding a k-of-n is what forfeits its credit. + if (!IsStackNeutralNop(opcode)) { + last_is_key = is_key; + last_is_xonly = is_xonly && !inside_noop; + } last_opcode = opcode; last_count = count; + last_a_count = a_count; last_is_push = is_push; } // A script can name more signatures than the spender supplies, including in branches that // never run. Where the witness carries the spend it is the honest count; a P2SH spend leaves // its signatures in the scriptSig, which is not visible here. + // + // A taproot script-path spend trails the script with a control block and, only when one is + // present, an annex, but CountWitnessSignatures excludes only the true last item. A control + // block can fall in the 64-73 byte range at a shallow merkle depth, so when an annex pushes + // it off the end, it may be miscounted as a signature. That can only inflate the count this + // rule caps against, never shrink it, so it can only undercharge, never overcharge: a + // documented imprecision, not a bug. if (witness && !witness->stack.empty()) { provable = std::min(provable, CountWitnessSignatures(*witness)); } From 5f9477d9e35211a239ffba560f5ea7d54b7779d8 Mon Sep 17 00:00:00 2001 From: LionThunderFingers <42521162+LionThunderFingers@users.noreply.github.com> Date: Sat, 10 Oct 2026 17:30:56 +0200 Subject: [PATCH 2/5] test: coverage for tapscript unproven pubkey counting Checks the taproot leaf against the P2WSH script that says the same thing, shape by shape, so the two are charged alike rather than only plausibly, including at the tolerance boundary: 1-of-11 is free and 1-of-12, 1-of-13 and 5-of-16 pay the same either way. Covers the hashlock shapes a 32 byte push must not be mistaken for a key in, the padded and inflated runs that forfeit the credit, the OP_CHECKSIG that opens a run crediting one of the k and not two, a k above 16 written as a minimal push, and the key stranded on the stack that neither rule reaches. --- src/test/script_tests.cpp | 119 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 119 insertions(+) diff --git a/src/test/script_tests.cpp b/src/test/script_tests.cpp index ab65cc4ccf06..1494ee76ffac 100644 --- a/src/test/script_tests.cpp +++ b/src/test/script_tests.cpp @@ -1532,6 +1532,33 @@ static CScriptWitness MultisigWitness(const int sigs, const CScript& script) return witness; } +/** OP_CHECKSIG ( OP_CHECKSIGADD)*(n-1) OP_NUMEQUAL, tapscript's k-of-n. */ +static CScript MultiAScript(const int k, const int n, const size_t key_size = 32, + const opcodetype terminator = OP_NUMEQUAL) +{ + CScript script{CScript() << std::vector(key_size, 1) << OP_CHECKSIG}; + for (int i{1}; i < n; ++i) { + script << std::vector(key_size, i + 1) << OP_CHECKSIGADD; + } + return script << k << terminator; +} + +/** A tapscript witness offering sigs signatures, as a spend of the given k-of-n would. */ +static CScriptWitness TapscriptWitness(const int sigs, const int n, const CScript& script) +{ + CScriptWitness witness; + // A key the spend does not sign for leaves an empty stack item in place of its signature. + for (int i{0}; i < n - sigs; ++i) { + witness.stack.emplace_back(); + } + for (int i{0}; i < sigs; ++i) { + witness.stack.emplace_back(64, i + 1); // a BIP340 signature, which carries no sighash byte + } + witness.stack.emplace_back(script.begin(), script.end()); + witness.stack.emplace_back(33, 0xc0); // the control block of a depth zero taproot leaf + return witness; +} + BOOST_AUTO_TEST_CASE(script_DataCarrierBytes) { using zeros = std::vector; @@ -1641,6 +1668,98 @@ BOOST_AUTO_TEST_CASE(script_DataCarrierBytes_unproven_pubkeys) << OP_2DROP << OP_2DROP << OP_DROP)); } +BOOST_AUTO_TEST_CASE(script_DataCarrierBytes_tapscript_unproven_pubkeys) +{ + using zeros = std::vector; + + // The same payload costs the same whichever way it is written. A 1-of-15 under P2WSH and + // the taproot leaf that says the same thing are charged alike, where once the leaf was free + BOOST_CHECK_EQUAL("132+0", DatacarrierBytesStr(MultisigScript(1, 15), MultisigWitness(1, MultisigScript(1, 15)))); + BOOST_CHECK_EQUAL("132+0", DatacarrierBytesStr(MultiAScript(1, 15), TapscriptWitness(1, 15, MultiAScript(1, 15)))); + + // An x-only key a signature is offered for is a spending condition, not data + BOOST_CHECK_EQUAL("0+0", DatacarrierBytesStr(CScript() << zeros(32) << OP_CHECKSIG)); + // Ordinary tapscript multisig keeps its spare keys, exactly as OP_CHECKMULTISIG does + for (const auto& [k, n] : {std::pair{2, 3}, std::pair{3, 5}, std::pair{1, 2}, std::pair{1, 3}, + std::pair{3, 3}, std::pair{5, 5}, std::pair{11, 15}, std::pair{20, 20}}) { + const CScript script{MultiAScript(k, n)}; + BOOST_CHECK_EQUAL(DatacarrierBytesStr(MultisigScript(k, n)), + DatacarrierBytesStr(script, TapscriptWitness(k, n, script))); + } + // Eleven keys behind one signature are free, as under OP_CHECKMULTISIG; past the tolerance + // every further key is payload, however honest the spend that offers it + BOOST_CHECK_EQUAL("0+0", DatacarrierBytesStr(MultiAScript(1, 11), TapscriptWitness(1, 11, MultiAScript(1, 11)))); + BOOST_CHECK_EQUAL("33+0", DatacarrierBytesStr(MultiAScript(1, 12), TapscriptWitness(1, 12, MultiAScript(1, 12)))); + BOOST_CHECK_EQUAL("66+0", DatacarrierBytesStr(MultiAScript(1, 13), TapscriptWitness(1, 13, MultiAScript(1, 13)))); + BOOST_CHECK_EQUAL("33+0", DatacarrierBytesStr(MultiAScript(5, 16), TapscriptWitness(5, 16, MultiAScript(5, 16)))); + for (const auto& [k, n] : {std::pair{1, 11}, std::pair{1, 12}, std::pair{1, 13}, std::pair{5, 16}}) { + const CScript script{MultiAScript(k, n)}; + BOOST_CHECK_EQUAL(DatacarrierBytesStr(MultisigScript(k, n), MultisigWitness(k, MultisigScript(k, n))), + DatacarrierBytesStr(script, TapscriptWitness(k, n, script))); + } + // Above 16 the key count is a minimal push rather than an OP_N, and still counts. A leaf may + // hold far more keys than OP_CHECKMULTISIG, so it is read up to MAX_PUBKEYS_PER_MULTI_A + BOOST_CHECK_EQUAL("33+0", DatacarrierBytesStr(MultiAScript(17, 28), TapscriptWitness(17, 28, MultiAScript(17, 28)))); + // The credit is the shape of the run, not the size of its keys: a leaf may hold either + BOOST_CHECK_EQUAL("0+0", DatacarrierBytesStr(MultiAScript(3, 5, 33), TapscriptWitness(3, 5, MultiAScript(3, 5, 33)))); + // A run closed by OP_NUMEQUALVERIFY says the same thing as one closed by OP_NUMEQUAL + const CScript verify{MultiAScript(3, 5, 32, OP_NUMEQUALVERIFY)}; + BOOST_CHECK_EQUAL("0+0", DatacarrierBytesStr(verify, TapscriptWitness(3, 5, verify))); + + // A 32 byte push is only a key where something spends it. The preimage of a hashlock and + // the hash it is checked against are neither of them keys, in a leaf or anywhere else + BOOST_CHECK_EQUAL("0+0", DatacarrierBytesStr(CScript() << OP_SHA256 << zeros(32) << OP_EQUALVERIFY + << zeros(33) << OP_CHECKSIG)); + BOOST_CHECK_EQUAL("0+0", DatacarrierBytesStr(CScript() << zeros(32) << OP_SHA256 << zeros(32) + << OP_EQUALVERIFY << zeros(32) << OP_CHECKSIG)); + + // Padding the run forfeits the credit rather than earning one, so an 11-of-15 written the + // long way round is charged as though it proved nothing but the key it opens on + CScript padded{CScript() << zeros(32) << OP_CHECKSIG}; + for (int i{1}; i < 15; ++i) { + padded << zeros(32) << OP_NOP << OP_CHECKSIGADD; + } + padded << 11 << OP_NUMEQUAL; + BOOST_CHECK_EQUAL("132+0", DatacarrierBytesStr(padded, TapscriptWitness(11, 15, padded))); + // ... while an OP_NOP does not hide the key beneath it from the opcode that spends it + BOOST_CHECK_EQUAL("0+0", DatacarrierBytesStr(CScript() << zeros(32) << OP_NOP << OP_CHECKSIG)); + + // Naming a k the run cannot satisfy forfeits the credit too + CScript inflated{CScript() << zeros(32) << OP_CHECKSIG}; + for (int i{1}; i < 15; ++i) { + inflated << zeros(32) << OP_CHECKSIGADD; + } + inflated << 20 << OP_NUMEQUAL; + BOOST_CHECK_EQUAL("132+0", DatacarrierBytesStr(inflated, TapscriptWitness(11, 15, inflated))); + + // The OP_CHECKSIG a run opens on credits one of the k already, so a 1-of-n is credited once + BOOST_CHECK_EQUAL("0+0", DatacarrierBytesStr(MultiAScript(1, 3), TapscriptWitness(1, 3, MultiAScript(1, 3)))); + // Naming more signatures than the spend offers earns nothing here as it does elsewhere + BOOST_CHECK_EQUAL("132+0", DatacarrierBytesStr(MultiAScript(11, 15), TapscriptWitness(1, 15, MultiAScript(11, 15)))); + + // A key inside an OP_FALSE OP_IF envelope is counted with the envelope, once + BOOST_CHECK_EQUAL("0+37", DatacarrierBytesStr(CScript() << OP_FALSE << OP_IF << zeros(32) + << OP_CHECKSIG << OP_ENDIF)); + // A dropped key is already counted as dropped data; it is not charged twice, and no credit + // drives the count below zero + BOOST_CHECK_EQUAL("0+35", DatacarrierBytesStr(CScript() << zeros(32) << OP_CHECKSIG << zeros(33) << OP_DROP)); + CScript over_credited{MultiAScript(3, 3)}; + over_credited << OP_3 << OP_NUMEQUAL << OP_3 << OP_NUMEQUAL; + BOOST_CHECK_EQUAL("0+0", DatacarrierBytesStr(over_credited)); + + // A 32 byte push nothing spends and no drop balances is reached by neither rule. It is + // stranded on the stack, which leaves the leaf unspendable, so it buys nothing. + CScript stranded; + for (int i{0}; i < 15; ++i) { + stranded << zeros(32); + } + BOOST_CHECK_EQUAL("0+0", DatacarrierBytesStr(stranded)); + // Balance the same pushes with drops and the run rule charges them whole + BOOST_CHECK_EQUAL("0+496", DatacarrierBytesStr(CScript{stranded} << OP_2DROP << OP_2DROP << OP_2DROP + << OP_2DROP << OP_2DROP << OP_2DROP + << OP_2DROP << OP_DROP)); +} + BOOST_AUTO_TEST_CASE(script_GetScriptForTransactionInput) { using zeros = std::vector; From 177e5cad72982d875f2f4f4e2ed97599045b9f25 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kyle=20=F0=9F=90=86?= Date: Sun, 11 Oct 2026 19:13:54 -0400 Subject: [PATCH 3/5] policy: read a tapscript k from 128 written with a zero high byte --- src/script/script.cpp | 6 ++++-- src/test/script_tests.cpp | 12 ++++++++++++ 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/src/script/script.cpp b/src/script/script.cpp index ac2c428a1741..b0c9c2175701 100644 --- a/src/script/script.cpp +++ b/src/script/script.cpp @@ -387,8 +387,10 @@ static int MultiACount(const opcodetype opcode, const std::vector if (const int small{SmallIntValue(opcode)}; small > 0) return small; if (opcode != 1 && opcode != 2) return -1; if (push_data.size() != size_t{opcode}) return -1; - // A minimal push carries no redundant high byte, and the sign bit is never set here. - if (push_data.back() == 0 || (push_data.back() & 0x80)) return -1; + // The sign bit is never set here, and a zero high byte is minimal only where it keeps the + // byte below it from reading as the sign, as in 128 written 80 00. + if (push_data.back() & 0x80) return -1; + if (push_data.back() == 0 && (push_data.size() == 1 || !(push_data[0] & 0x80))) return -1; int value{push_data[0]}; if (push_data.size() == 2) value |= int{push_data[1]} << 8; if (value <= 16 || value > int{MAX_PUBKEYS_PER_MULTI_A}) return -1; diff --git a/src/test/script_tests.cpp b/src/test/script_tests.cpp index 1494ee76ffac..670f2e032eb5 100644 --- a/src/test/script_tests.cpp +++ b/src/test/script_tests.cpp @@ -1700,6 +1700,18 @@ BOOST_AUTO_TEST_CASE(script_DataCarrierBytes_tapscript_unproven_pubkeys) // Above 16 the key count is a minimal push rather than an OP_N, and still counts. A leaf may // hold far more keys than OP_CHECKMULTISIG, so it is read up to MAX_PUBKEYS_PER_MULTI_A BOOST_CHECK_EQUAL("33+0", DatacarrierBytesStr(MultiAScript(17, 28), TapscriptWitness(17, 28, MultiAScript(17, 28)))); + // From 128 the minimal push carries a zero high byte to keep the sign clear, as in 80 00 + for (const int k : {127, 128, 255, 256}) { + const CScript script{MultiAScript(k, k + 2)}; + BOOST_CHECK_EQUAL("0+0", DatacarrierBytesStr(script, TapscriptWitness(k, k + 2, script))); + } + // ... while a zero high byte nothing needs is not minimal, and forfeits the credit + CScript non_minimal{CScript() << zeros(32) << OP_CHECKSIG}; + for (int i{1}; i < 28; ++i) { + non_minimal << zeros(32) << OP_CHECKSIGADD; + } + non_minimal << std::vector{17, 0} << OP_NUMEQUAL; + BOOST_CHECK_EQUAL("561+0", DatacarrierBytesStr(non_minimal, TapscriptWitness(17, 28, non_minimal))); // The credit is the shape of the run, not the size of its keys: a leaf may hold either BOOST_CHECK_EQUAL("0+0", DatacarrierBytesStr(MultiAScript(3, 5, 33), TapscriptWitness(3, 5, MultiAScript(3, 5, 33)))); // A run closed by OP_NUMEQUALVERIFY says the same thing as one closed by OP_NUMEQUAL From 06a878f38abd9ab27ef457b4d9eb3ec6e6ad1bf6 Mon Sep 17 00:00:00 2001 From: Chris Guida Date: Wed, 14 Oct 2026 11:34:01 -0600 Subject: [PATCH 4/5] policy: avoid a narrowing conversion in MultiACount --- src/script/script.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/script/script.cpp b/src/script/script.cpp index b0c9c2175701..057d90cc7e21 100644 --- a/src/script/script.cpp +++ b/src/script/script.cpp @@ -386,7 +386,7 @@ static int MultiACount(const opcodetype opcode, const std::vector { if (const int small{SmallIntValue(opcode)}; small > 0) return small; if (opcode != 1 && opcode != 2) return -1; - if (push_data.size() != size_t{opcode}) return -1; + if (push_data.size() != static_cast(opcode)) return -1; // The sign bit is never set here, and a zero high byte is minimal only where it keeps the // byte below it from reading as the sign, as in 128 written 80 00. if (push_data.back() & 0x80) return -1; From d6ec42b98f00195c865afc9f41f3e672358ccc00 Mon Sep 17 00:00:00 2001 From: Chris Guida Date: Wed, 14 Oct 2026 11:34:01 -0600 Subject: [PATCH 5/5] test: keep MultiAScript key bytes in range --- src/test/script_tests.cpp | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/test/script_tests.cpp b/src/test/script_tests.cpp index 670f2e032eb5..8c38e468c931 100644 --- a/src/test/script_tests.cpp +++ b/src/test/script_tests.cpp @@ -1538,7 +1538,8 @@ static CScript MultiAScript(const int k, const int n, const size_t key_size = 32 { CScript script{CScript() << std::vector(key_size, 1) << OP_CHECKSIG}; for (int i{1}; i < n; ++i) { - script << std::vector(key_size, i + 1) << OP_CHECKSIGADD; + // the byte only has to vary between keys; past 255 it wraps + script << std::vector(key_size, static_cast(i + 1)) << OP_CHECKSIGADD; } return script << k << terminator; }