Skip to content

policy: Count data in a dead conditional branch - #400

Open
jasonsopko wants to merge 13 commits into
bitcoinknots:29.x-knotsfrom
jasonsopko:policy-dead-branch-data
Open

jasonsopko wants to merge 13 commits into
bitcoinknots:29.x-knotsfrom
jasonsopko:policy-dead-branch-data

Conversation

@jasonsopko

@jasonsopko jasonsopko commented Sep 9, 2026 •

Copy link
Copy Markdown

What

Counts data in any conditional part that constants make unreachable, including the dead part of an OP_ELSE, behind a new -rejectdeadbranches (default on, reset by -corepolicy). With it off the detector is the old one byte for byte.

Why

23e8f9466cd895c21a041155dba377f1a77a46cec13dcb14c430fa9c75f8786a at 969961 carried a 37 KB JPEG XL image in OP_1 OP_NOTIF branches across 25 P2WSH inputs; only OP_FALSE OP_IF was counted. The guard is worked out through stack moves, arithmetic, comparisons, no-ops and the altstack, so OP_1 OP_NOP OP_NOTIF and the like are counted too. Anything read from the witness or a signature check is unknown and never counted, which is how spendable scripts branch; the Lightning to_local, HTLC and anchor scripts count zero.

How I tested

The -Werror build and the full unit and functional suites pass on this branch merged into the 29.4.3rc1 candidate, and on the branch alone apart from tool_cli_completion.py, which needs the regenerated completions the release adds. The branch carries #319 and #422. On the earlier head, ASan/UBSan was clean, and a fuzz target checked that with the switch off the result matches the original function, and that every part marked dead leaves the interpreter's verdict unchanged when an OP_RETURN is put in it, over 4.9 million inputs in 45 minutes. Over the 3,018,541 transactions since the fork it counts one: the transaction above, at 37,854 bytes.

Risk and rollback

Policy only, and it reads input scripts only. -rejectdeadbranches=0 turns it off.

Edit: 2026-10-01 added a link to a regtest replay to Why; 2026-10-04 added paragraphs on contiguity and carving. 2026-10-14 removed them; the rule does not depend on them. How I tested now describes the head rebased for 29.4.3.

@chrisguida

chrisguida commented Sep 10, 2026 •

Copy link
Copy Markdown

Why allow dead branches at all? No one is using this method. We could just make a dead branch filter, maybe even have it on by default, then when rdts expires, this would supersede it (for the tapscript part)?

@jasonsopko

Copy link
Copy Markdown
Author

It does not allow them. Under the defaults, any byte the detector counts inside a dead branch rejects the transaction as txn-datacarrier-nonstandard, whatever -datacarriersize is set to: the counted bytes land in the non-standard bucket, and validation.cpp rejects that bucket outright unless the operator has set -acceptnonstddatacarrier=1 (or -corepolicy, which sets it). Only then does the size budget come into it. That is the switch the OP_FALSE OP_IF envelope has been behind in Knots; this PR widens what the detector recognizes as a dead branch, so it is the default-on filter you describe, with the same opt-out the existing envelope has.

It reaches tapscript already. GetScriptForTransactionInput hands the detector the tapscript of a script-path spend the same way it hands it a P2WSH witness script, so when the RDTS ban on OP_IF in tapscript expires, this policy is what remains, for both envelope shapes.

On nobody using it: one transaction so far, the one in the description. Generalizing the trigger rather than adding OP_1 OP_NOTIF next to OP_FALSE OP_IF is so the next variant, OP_2 OP_NOTIF or a pushed 0x80 in front of OP_IF, does not need another PR.

jasonsopko added a commit to jasonsopko/bitcoinknots that referenced this pull request Sep 27, 2026
OLGA hides a payload in P2WSH outputs at the dust threshold, and the
detector keys on its "stamp:" magic. The same layout with "ACME" has put
over 100 transactions on chain since 957497, and changing the magic is
free.

Judge the bytes instead. A hash or x-only key is uniformly random, so
one byte value eight times in a program, a run of six, or a taproot key
off the curve marks it as data, along with its same-type same-value
siblings. Three or more distinct P2WSH outputs sharing one value of 546
sat or less are data on their own: encoders pay every chunk the same,
batch payouts on chain start at 600 sat, Lightning anchors are a pair
and HTLC amounts differ. Small taproot runs are left to the curve check,
since the ones on chain are ordinals postage with no data in the
outputs. The all-zero burn hash is left alone. The detector sits behind
-rejectfakeoutputs, default on and reset by -corepolicy.

Over the 3,018,541 transactions since the fork it newly rejects 45: 14
funding transactions of the fake-key and JXL encoders, 14 swap memos
continued as ASCII in a P2WPKH hash, 11 ACME-style P2WSH payloads and 6
taproot outputs tagged DECODE WITNESS.

Checked on Ubuntu 24.04 against 29.4.2: a gcc 13.3 -Werror build with
wallet and ZMQ has no warnings, and all 147 ctest suites and the full
functional suite pass, alone and merged with bitcoinknots#400, bitcoinknots#319 and bitcoinknots#422. The
clang 18 ASan/UBSan build passes the unit suite and the policy
functional tests.
@jasonsopko
jasonsopko force-pushed the policy-dead-branch-data branch from f2e6893 to e704b7a Compare September 27, 2026 09:34
@jasonsopko

Copy link
Copy Markdown
Author

Rebased on 29.4.2 and squashed. The rule is now behind -rejectdeadbranches (default on, reset by -corepolicy); with it off the detector is the old one byte for byte. The guard is worked out through any opcodes constants decide, so OP_1 OP_NOP OP_NOTIF is caught, and OP_ELSE switches between the counted and live parts. Tests moved to mempool_dead_branch.py. It merges cleanly with #319; with #422 the one conflict is the DatacarrierBytes signature line, where both sides stay. Previous head: f2e6893.

@jasonsopko

jasonsopko commented Oct 1, 2026 •

Copy link
Copy Markdown
Author

The transaction above replays on regtest with the fork and RDTS active and the default policy on: stock 29.4.2 accepts it, this branch refuses it with txn-datacarrier-nonstandard, and -rejectdeadbranches=0 or -corepolicy=1 accept it again.

Nothing on the branch has changed since e704b7a. It sits on the current 29.x-knots tip and merges cleanly with #319, #389 and #435; with #422 the one conflict is the DatacarrierBytes signature line, where both sides stay.

@kwsantiago @MarcanoFilms review welcome.

Edit: 2026-10-14 removed the link to the replay script, as in the description. The branch this comment describes has been replaced since; see the latest comment of mine below.

@jasonsopko

Copy link
Copy Markdown
Author

AuthintegX raised a cost question in the Knots Discord: FindDeadParts has no cap of its own and runs before the transaction is checked further. I measured it, and the concern was right.

DatacarrierBytes runs before IsWitnessStandard checks the witness size, and OP_ROLL with a known depth moved up to 1,000 stack entries. A script that fills the stack and repeats <998> OP_ROLL took 506 ms to analyze at 390 KB, against 1.7 ms for the plain scan, on a transaction the node then rejects at no cost to the sender.

Pushed 68e6db8:

  • Inputs whose witness is over -maxscriptsize are skipped. They fail IsWitnessStandard right after, so the analysis was wasted on them.
  • Each opcode costs one step and each OP_ROLL also costs its depth, stopping at four steps per script byte. Stopping marks nothing more, so it can only undercount, the same as a condition read from the witness.

Median time for one DatacarrierBytes call on one script, Release build, x86_64:

Shape Size Plain scan Before After Interpreter
fill, then <998> OP_ROLL repeated 1,647 B 8 µs 882 µs 92 µs 236 µs
fill, then <998> OP_ROLL repeated 390 KB 1.6 ms 506 ms 9.7 ms 116 ms
OP_1 then OP_1ADD repeated 1,650 B 10 µs 136 µs 138 µs 83 µs
OP_2ROT OP_2SWAP OP_ROT repeated 390 KB 2.4 ms 20 ms 23 ms 15 ms
two 520-byte values, OP_2DUP OP_EQUAL OP_DROP repeated 390 KB 2.4 ms 7.2 ms 8.0 ms 15.7 ms
OP_1 OP_IF nested, then closed 585 KB 4.3 ms 19 ms 21 ms 12 ms
dead branch full of 520-byte pushes 390 KB 0.2 ms 0.4 ms 0.4 ms 0.2 ms

The 390 KB rows no longer reach the analysis in a node, since anything over 1,650 bytes is skipped; they show the bound holds without the size gate. The worst left is the arithmetic chain at about 85 ns per byte, the same order as the interpreter on that script.

Over every transaction from 961640 to 974573 (3,103,397), the counts are byte-identical before and after.

Thanks to AuthintegX for raising it.

Benchmark (built into test_bitcoin, run with DEADBRANCH_COST=1)
// Local measurement, not for submission. Times CScript::DatacarrierBytes with
// and without dead-branch tracking on adversarial scripts of growing size, and
// the interpreter on the same script for comparison. Runs only with
// DEADBRANCH_COST=1 in the environment; prints one line per case.
#include <crypto/sha256.h>
#include <hash.h>
#include <script/interpreter.h>
#include <script/script.h>
#include <test/util/setup_common.h>

#include <boost/test/unit_test.hpp>

#include <algorithm>
#include <chrono>
#include <cstdio>
#include <cstdlib>
#include <functional>
#include <string>
#include <vector>

namespace {
using Clock = std::chrono::steady_clock;

double TimeUs(const std::function<void()>& f, int reps)
{
    std::vector<double> runs;
    for (int i{0}; i < reps; ++i) {
        const auto t0{Clock::now()};
        f();
        runs.push_back(std::chrono::duration<double, std::micro>(Clock::now() - t0).count());
    }
    std::sort(runs.begin(), runs.end());
    return runs[runs.size() / 2];
}

CScript Fill(size_t items)
{
    CScript s;
    for (size_t i{0}; i < items; ++i) s << OP_1;
    return s;
}

// 999 known items, then <998> OP_ROLL until the size is reached: each roll moves 998 entries
CScript RollBomb(size_t size)
{
    CScript s{Fill(999)};
    while (s.size() + 4 <= size) s << CScriptNum{998} << OP_ROLL;
    return s;
}

// Two different 520-byte values, then OP_2DUP OP_EQUAL OP_DROP: each compare reads 520 bytes
CScript EqualBomb(size_t size)
{
    CScript s;
    s << std::vector<unsigned char>(520, 0xaa) << std::vector<unsigned char>(520, 0xab);
    while (s.size() + 3 <= size) s << OP_2DUP << OP_EQUAL << OP_DROP;
    return s;
}

// OP_1 then OP_1ADD repeatedly: one new value per byte
CScript ArithBomb(size_t size)
{
    CScript s;
    s << OP_1;
    while (s.size() + 1 <= size) s << OP_1ADD;
    return s;
}

// 999 known items, then OP_2ROT OP_2SWAP OP_ROT repeatedly
CScript ShuffleBomb(size_t size)
{
    CScript s{Fill(999)};
    while (s.size() + 3 <= size) s << OP_2ROT << OP_2SWAP << OP_ROT;
    return s;
}

// Known conditions nested as deep as the size allows, then closed
CScript NestBomb(size_t size)
{
    CScript s;
    size_t depth{0};
    while (s.size() + 3 <= size) { s << OP_1 << OP_IF; ++depth; }
    for (size_t i{0}; i < depth; ++i) s << OP_ENDIF;
    return s;
}

// A dead branch full of pushes, the shape #400 exists for
CScript DeadBranchData(size_t size)
{
    CScript s;
    s << OP_1 << OP_NOTIF;
    while (s.size() + 524 <= size) s << std::vector<unsigned char>(520, 0x42);
    s << OP_ENDIF << OP_1;
    return s;
}
} // namespace

BOOST_FIXTURE_TEST_SUITE(deadbranch_cost_tests, BasicTestingSetup)

BOOST_AUTO_TEST_CASE(deadbranch_cost)
{
    if (!std::getenv("DEADBRANCH_COST")) return;
    const std::vector<std::pair<std::string, std::function<CScript(size_t)>>> shapes{
        {"roll", RollBomb}, {"equal", EqualBomb}, {"arith", ArithBomb},
        {"shuffle", ShuffleBomb}, {"nest", NestBomb}, {"deadbranch", DeadBranchData},
    };
    std::printf("shape size_bytes scan_off_us scan_on_us interpreter_us sha256d_us\n");
    for (const size_t size : {size_t{1650}, size_t{10000}, size_t{100000}, size_t{390000}}) {
        std::vector<unsigned char> blob(size, 0x5a);
        const double hash_us{TimeUs([&] { uint256 h{Hash(blob)}; (void)h; }, 21)};
        for (const auto& [name, make] : shapes) {
            const CScript script{make(size)};
            const int reps{size > 100000 ? 7 : 21};
            const double off{TimeUs([&] { (void)script.DatacarrierBytes(0, nullptr, false); }, reps)};
            const double on{TimeUs([&] { (void)script.DatacarrierBytes(0, nullptr, true); }, reps)};
            const double interp{TimeUs([&] {
                std::vector<std::vector<unsigned char>> stack;
                ScriptExecutionData execdata;
                execdata.m_validation_weight_left_init = true;
                execdata.m_validation_weight_left = 1 << 30;
                ScriptError err;
                (void)EvalScript(stack, script, SCRIPT_VERIFY_TAPROOT, BaseSignatureChecker{}, SigVersion::TAPSCRIPT, execdata, &err);
            }, reps)};
            std::printf("%s %zu %.1f %.1f %.1f %.1f\n", name.c_str(), size_t{script.size()}, off, on, interp, hash_us);
        }
    }
}

BOOST_AUTO_TEST_SUITE_END()

@NoQuarter21M

NoQuarter21M commented Oct 11, 2026 •

Copy link
Copy Markdown

Concept ACK.

Data in a constant-guarded dead branch is the same thing the OP_FALSE OP_IF envelope already counts; OP_1 OP_NOTIF is its mirror. Policy only: all callers are mempool acceptance and fee weight, no consensus path touched, existing opt-outs apply.

(edited to remove incorrect approach note)

@booozytheclown

Copy link
Copy Markdown

Concept ACK.

LionThunderFingers and others added 7 commits October 14, 2026 15:25
… 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

  <pubkey> OP_CHECKSIG (<pubkey> OP_CHECKSIGADD)* <k> 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.
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.
…rrier' into policy-dead-branch-data

bitcoinknots#422 at eea2467 changes DatacarrierBytes too. This branch builds on it so the 29.4.3 assembly can merge it after k422.
The detector counts the OP_FALSE OP_IF envelope but not its mirror, OP_1
OP_NOTIF, or any other branch constants make unreachable. 23e8f946... at
969961 carried a 37 KB JPEG XL image that way, and nothing on the fence
saw it.

Follow the script as far as constants decide it, through stack moves,
arithmetic, comparisons, no-ops and the altstack, and count every
conditional part that can never run, including the dead part of an
OP_ELSE. Anything read from the witness or a signature check is unknown
and never counted, which is how spendable scripts branch. The rule sits
behind -rejectdeadbranches, default on and reset by -corepolicy; with it
off the detector is the old one byte for byte.

Checked on Ubuntu 24.04 against 29.4.2: a gcc 13.3 -Werror build with
wallet and ZMQ has no warnings, and all 147 ctest suites and the full
functional suite pass, alone and merged with bitcoinknots#389, bitcoinknots#319 and bitcoinknots#422. The
clang 18 ASan/UBSan build passes the unit suite and the policy
functional tests. A fuzz target checked that with the switch off the
result matches the original function, and that every part marked dead
leaves the interpreter's verdict unchanged when an OP_RETURN is put in
it, over 4.9 million inputs in 45 minutes. Over all 3,018,541
transactions since the fork it counts one: 23e8f946..., at 37,854 bytes.
FindDeadParts ran on every input before IsWitnessStandard checks the
witness size, and OP_ROLL with a known depth moved up to 1,000 stack
entries. A 390 KB script repeating <998> OP_ROLL took 506 ms to analyze
against 1.7 ms for the plain scan, on a transaction the node then
rejects at no cost to the sender. AuthintegX raised it in the Knots
Discord.

Skip inputs whose witness is over -maxscriptsize, which fail
IsWitnessStandard right after, and charge each opcode one step and each
OP_ROLL its depth, stopping at four steps per byte. Stopping marks
nothing more, so it can only undercount, the same as a condition read
from the witness.

The roll case drops to 92 us at 1,650 bytes (was 882) and 9.7 ms at
390 KB. The worst left is an arithmetic chain at about 85 ns per byte,
the same order as the interpreter on that script. Counts over all
3,103,397 transactions from 961640 to 974573 are unchanged.
gcc 11 at -O2 cannot see that OP_WITHIN reads lo only after Num() has
set it, and the previous-releases CI job (DEBUG, -Werror) fails with
"'lo' may be used uninitialized". Start every int64_t the walk fills
through Num() at zero so no compiler has to prove it. No behavior
change: each one is still read only where Num() returned true.
@jasonsopko
jasonsopko force-pushed the policy-dead-branch-data branch from 68e6db8 to 847d6a2 Compare October 15, 2026 03:10
@jasonsopko

Copy link
Copy Markdown
Author

Pushed 847d6a2, three changes so the branch can go into 29.4.3:

Tested on the new head: -Werror build, ctest 128 of 128, functional suite without wallet 202 passed, 138 skipped, 1 failed: tool_cli_completion.py, which fails on anything rooted before the release regenerated the completions (#429 had the same) and passes once merged. Merged into the 29.4.3rc1 candidate (chrisguida/bitcoin a7d7d2e): no conflict, the result differs from the candidate by exactly this PR's diff, and on that tree ctest 131 of 131 and the functional suite 218 passed, 141 skipped, 0 failed. Spec line proposed on luke-jr/tmp#5.

jasonsopko added a commit to jasonsopko/bitcoinknots that referenced this pull request Oct 15, 2026
OLGA hides a payload in P2WSH outputs at the dust threshold, and the
detector keys on its "stamp:" magic. The same layout with "ACME" has put
over 100 transactions on chain since 957497, and changing the magic is
free.

Judge the bytes instead. A hash or x-only key is uniformly random, so
one byte value eight times in a program, a run of six, or a taproot key
off the curve marks it as data, along with its same-type same-value
siblings. Three or more distinct P2WSH outputs sharing one value of 546
sat or less are data on their own: encoders pay every chunk the same,
batch payouts on chain start at 600 sat, Lightning anchors are a pair
and HTLC amounts differ. Small taproot runs are left to the curve check,
since the ones on chain are ordinals postage with no data in the
outputs. The all-zero burn hash is left alone. The detector sits behind
-rejectfakeoutputs, default on and reset by -corepolicy.

Over the 3,018,541 transactions since the fork it newly rejects 45: 14
funding transactions of the fake-key and JXL encoders, 14 swap memos
continued as ASCII in a P2WPKH hash, 11 ACME-style P2WSH payloads and 6
taproot outputs tagged DECODE WITNESS.

Checked on Ubuntu 24.04 against 29.4.2: a gcc 13.3 -Werror build with
wallet and ZMQ has no warnings, and all 147 ctest suites and the full
functional suite pass, alone and merged with bitcoinknots#400, bitcoinknots#319 and bitcoinknots#422. The
clang 18 ASan/UBSan build passes the unit suite and the policy
functional tests.
@jasonsopko

Copy link
Copy Markdown
Author

Fork CI on 847d6a2: the gcc 11 previous-releases job compiles clean, so the zero-init commit does what it was for. Nine jobs fail, each at tool_cli_completion.py, the base artifact above: eight stop there, since CI runs the suite with --failfast, and the Win64 job, which runs it in full, has no other failure. Every job that ran without the functional suite passes.

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.

6 participants