Skip to content

policy: Count odd pushes in a multisig script as data - #5

Open
jasonsopko wants to merge 15 commits into
29.x-plumbfrom
fix/odd-key-slots
Open

jasonsopko wants to merge 15 commits into
29.x-plumbfrom
fix/odd-key-slots

Conversation

@jasonsopko

Copy link
Copy Markdown
Contributor

What

Counts the pushes in a multisig script that cannot be a key as data under -rejectfakemultisig, and takes Kyle's later fix from knots#422 for a tapscript k from 128, with Chris's two one-line casts from privkeyio/bitcoin#2. For #4.

Why

In a 1-of-n OP_CHECKMULTISIG with the signing key last, consensus never examines the other key slots, so they take any push (#4). A 37 or 100 byte push there counted as neither a key nor data: 14 slots carry 1,400 bytes per input past the filter.

The rule, in a script that runs OP_CHECKMULTISIG or OP_CHECKMULTISIGVERIFY:

  • Every push of 6 or more bytes that cannot be a key counts as data in full, outside the allowance of 10 keys. A push is left out when it is a digest a hash lock compares (the size the hash opcode before it outputs, followed by OP_EQUAL or OP_EQUALVERIFY), or when the drop and envelope rules count it already.
  • A push of 5 bytes or less counts too when another push follows it, with nothing between but OP_NOP, OP_CODESEPARATOR or an upgradable NOP. A script reads a number with the opcode after it, and a key count, the one number with keys pushed after it, is left alone. Without this, nine chained 1-of-10s with bare 5 byte slots carry 405 bytes in one input.
  • In a witness script a 65 byte push counts too: segwit policy refuses an uncompressed key wherever it checks one, and ten such slots would otherwise carry 650 bytes inside the allowance.

Unlike the slot rule #4 suggests, none of this needs a literal key count, so a computed count and no-ops between the slots are covered too.

knots#422 has moved past the commit Plumb merges. Kyle's eea2467 is here, its code as it is there and its tests next to Plumb's older ones: a tapscript multi_a k from 128 is written 80 00, and Plumb read that as not minimal and counted the keys. Chris's 06a878f38a and d6ec42b98f from privkeyio/bitcoin#2 follow it under his name: a cast MSVC needs in MultiACount, and one in a test helper that the k test needs past 255 keys. knots#422 has both inside its own commits.

How I tested

  • Unit tests for the -rejectfakemultisig ignores key slots whose push is not 33 or 65 bytes #4 case, slots of 100, 5 and 1 bytes, a computed key count, OP_NOP and OP_CODESEPARATOR between slots, 32 byte slots, OP_CHECKMULTISIGVERIFY, OP_EQUAL with no hash opcode before it, 65 byte slots in a witness script and short pushes in a dropped run, and for what real multisig scripts carry besides keys: hash locks, an OP_FALSE OP_IF tag, dropped pushes, timelock numbers, a key count above 16, uncompressed keys in a redeem script. The functional test spends the -rejectfakemultisig ignores key slots whose push is not 33 or 65 bytes #4 case and a 1-of-20 with 5 byte slots on regtest: each is refused with the filter on, and the 100 byte case is accepted with -rejectfakemultisig=0.
  • Merged onto plumb7 (tree 7ad0689e2d): ctest 146 of 146 and the full functional suite pass.
  • Every transaction from 961640 to 977870 with an input whose P2SH or P2WSH script runs CHECKMULTISIG, 179,183 of them, through cesspool's plumb-check harness built from this tree and from plumb7: the same verdict and data count for every one.
  • P2P relay on regtest, plumb7 and this tree built with the same flags, zero-fee spends from one inbound peer, per transaction:
spend this tree plumb7
1-of-15, 14 slots of 37 bytes 0.09 ms 0.11 ms
1-of-16, 15 slots of 100 bytes 0.09 ms 0.11 ms
213 pushes of 6 bytes 0.11 ms 0.16 ms
1-of-20, 19 slots of 5 bytes 0.08 ms 0.11 ms
249 pushes of 5 bytes 0.11 ms 0.18 ms
1,000 inputs, one 37 byte slot each 2.68 ms 4.02 ms
900 plain 2-of-3 inputs 4.85 ms 4.35 ms

This tree refuses the first six at the data carrier check, before the fee check plumb7 refuses them at. The peer stayed connected throughout, and the median getblockcount took under 1 ms. Over four runs of this same bitcoind today the plain row read 4.35 to 4.85 ms, and plumb7's 4.19 to 4.36 ms.

GitHub will show a conflict in test/functional/mempool_datacarrier.py against 29.x-plumb. The test merge kept both blocks. It also added /*dead_branches=*/true to the five script_tests calls this PR adds that pass bare_envelopes, since plumb7's DatacarrierBytesStr takes dead_branches first; GitHub does not flag those.

Risk and rollback

Policy only, on by default with the filter. A real multisig spend now has bytes counted toward -datacarriersize if it holds a push of 6 or more bytes that cannot be a key, no hash lock digest and not counted by the drop or envelope rules, or a short push that another push follows. None since the fork has either, and no multisig witness script since the fork has a 65 byte push. With -rejectdeadbranches too, a push in a dead branch of a multisig script counts under both rules: 37 bytes there count as 37 + 41. At the defaults that changes nothing, because the dead-branch bytes alone get the input refused (txn-datacarrier-nonstandard); it matters only with -acceptnonstddatacarrier=1. -rejectfakemultisig=0 turns the whole filter off, and reverting the merge backs this out.

Still unsure about

  • A hash lock digest still counts as nothing, and a script cannot tell a real digest from data. In the key slots that is -rejectfakemultisig ignores key slots whose push is not 33 or 65 bytes #4 again at 32 bytes of data per 36 of script: a 1-of-15 whose 14 slots are OP_DUP OP_SHA256 <32 bytes> OP_EQUAL carries 448 bytes, and a 1-of-20 and a 1-of-18 in one input carry 1,152, accepted on regtest. A cap on digests per script, as on keys, would bound it: no multisig script since the fork has more than one digest before OP_EQUAL or 11 before OP_EQUALVERIFY.
  • The allowance itself still lets ten unproven 33 byte slots carry 330 bytes per script, by design of knots#422. In a redeem script, where an uncompressed key is still valid, seven 65 byte slots carry 455 bytes the same way.
  • Some data pushes still count as nothing. Outside multisig scripts, <37 bytes> <key> OP_NIP OP_CHECKSIG and <37 bytes> <key> OP_SWAP OP_DROP OP_CHECKSIG do. Inside one, so does a push of 5 bytes or less with any opcode but a push or a no-op after it: a 1-of-15 whose 14 slots are <5 bytes> OP_CHECKSEQUENCEVERIFY or <5 bytes> OP_SIZE OP_DROP is accepted on regtest. That costs 7 or 8 bytes of script for 5 of data, where a bare slot costs 6.
  • A multisig in the OP_ELSE half of OP_FALSE OP_IF ... OP_ELSE ... OP_ENDIF is skipped as part of the envelope: 15 slots of 100 bytes there count 2 bytes. At the defaults those 2 bytes get the input refused (txn-datacarrier-nonstandard). With -acceptnonstddatacarrier=1 they are held to -datacarriersize instead, so it passes.

kwsantiago and others added 15 commits October 2, 2026 00:34
The run rule from #319 changes DatacarrierBytes for every node with no
way to turn it off on its own. Give it its own option, default on, and
let -corepolicy reset it like the other Knots spam filters. With the
option off, counting is exactly what it was before #319: only the last
push before OP_DROP counts, and OP_2DROP ends nothing.

The test framework runs nodes with -corepolicy, so the two functional
tests that rely on the rule now ask for it, and mempool_datacarrier.py
checks that -rejectbareenvelopes=0 lets the large envelope through.
… 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 396 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

  <pubkey> OP_CHECKSIG (<pubkey> OP_CHECKSIGADD)* <k> OP_NUMEQUAL

credits k, whatever size its keys are. Without it every honest tapscript
multisig past three keys would be charged for the keys it does not spend,
which is the opposite of the point: a 20-of-20 would cost 561 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.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MeQuiSdZEYJs491niM5zeo
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.

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, and the key stranded on the
stack that neither rule reaches.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MeQuiSdZEYJs491niM5zeo
Give #422's rule its own option, default on, reset by -corepolicy.

#422 skips keys in a dropped push run because the caller counts the run
whole; with -rejectbareenvelopes=0 the caller counts only the last push
before an OP_DROP, so only that key is skipped.

The allowance of 10 unproven keys per script is #422's own since its
2026-10-04 push; this commit no longer changes it. Over 961640 to 975264
(3,278,236 transactions) it refuses 90 of the 92 bpub reveals since the
fork; the two it misses are single-input 1-of-8 and 1-of-9 reveals
carrying 465 bytes. The one other refusal is a Liquid emergency spend
through the 2-of-3 recovery branch, which leaves 16 keys unsigned. The
widest real wallet since the fork leaves 10 keys unsigned, a 2-of-3
vault with timelocked 2-of-3 and 3-of-6 recovery branches spent with
two signatures; the unit test keeps that spend.
In a 1-of-n OP_CHECKMULTISIG with the signing key last, consensus never
examines the other key slots, so they take any push up to the element
size limit. A 37 or 100 byte push there counted as neither a key nor
data, which let 14 slots carry 1,400 bytes per input past
-rejectfakemultisig (#4).

In a script that runs OP_CHECKMULTISIG or OP_CHECKMULTISIGVERIFY, count
every push of 6 or more bytes that is no key as data in full, outside
the allowance of 10 keys. A push is left out when it is a digest a hash
lock compares (the size the hash opcode before it outputs, followed by
OP_EQUAL or OP_EQUALVERIFY), or when the drop and envelope rules count
it already. In a witness script a 65 byte push counts too: segwit
policy refuses an uncompressed key wherever it checks one, and ten such
slots would otherwise carry 650 bytes inside the allowance.

A push of 5 bytes or less counts too when another push follows it,
with nothing between but OP_NOP, OP_CODESEPARATOR or an upgradable
NOP. A script reads a number with the opcode after it, and a key
count, the one number with keys pushed after it, is left alone.
Without this a bare slot took a push of 5 bytes or less free. One
with any other opcode after it, such as OP_CHECKSEQUENCEVERIFY,
still does.

Neither rule needs a literal <n>, so a computed key count and no-ops
between the slots are covered too.
The case from #4: a 1-of-15 with the signing key last,
whose 14 other slots hold 37 or 100 byte pushes. Also 5 and 1 byte
slots, a computed key count, no-ops between the slots, 32 byte slots,
OP_CHECKMULTISIGVERIFY, OP_EQUAL with no hash opcode before it, 65 byte
slots in a witness script and short pushes in a dropped run, and what
real multisig scripts carry besides keys: hash locks, an OP_FALSE OP_IF
tag, dropped pushes, timelock numbers, a key count above 16 and
uncompressed keys in a redeem script. The functional test spends the
real case and a 1-of-20 with 5 byte slots on regtest, refused with the
filter on, and the 100 byte case accepted with -rejectfakemultisig=0.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants