Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
4aa4b89
policy: count bare data envelopes (OP_2DROP/pushnum runs) as datacarrier
kwsantiago Jul 3, 2026
e133301
test: functional coverage for bare datacarrier envelopes
kwsantiago Jul 3, 2026
6507ed3
test: the 30 kB no-op leaf is a data envelope under the new run rule
kwsantiago Sep 30, 2026
5e3153e
test: keep the sigops ratio script 2 push under the datacarrier limit
kwsantiago Oct 5, 2026
55ca841
policy: count pubkeys no signature can prove as datacarrier bytes
kwsantiago Oct 4, 2026
f2e7e09
test: coverage for unproven pubkey datacarrier counting
kwsantiago Oct 4, 2026
cd8907e
policy: count tapscript pubkeys no signature can prove as datacarrier…
LionThunderFingers Oct 10, 2026
44ec48c
test: coverage for tapscript unproven pubkey counting
LionThunderFingers Oct 10, 2026
eea2467
policy: read a tapscript k from 128 written with a zero high byte
kwsantiago Oct 11, 2026
c4d36b4
Merge remote-tracking branch 'privkeyio/policy-unproven-pubkey-dataca…
jasonsopko Oct 15, 2026
6af1d26
policy: Count data in a dead conditional branch
jasonsopko Sep 27, 2026
9c81d40
policy: Bound the dead-branch analysis to the script size
jasonsopko Oct 2, 2026
847d6a2
policy: Zero the number locals in the dead-branch walk
jasonsopko Oct 15, 2026
2a328f1
policy: Count data in output hashes and keys
jasonsopko Sep 27, 2026
c5bb90b
policy: Do not count an output that pays a spent coin's script
jasonsopko Oct 13, 2026
ecc032f
policy: Mark the outputs paying each spent script once
jasonsopko Oct 13, 2026
40e0e6f
policy: Put the cost of a chosen key at 2^128, not 2^160
jasonsopko Oct 13, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions doc/release-notes-data-outputs.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
Policy
------

- Outputs whose hash or key field is data rather than a hash or key now count
as data carrier bytes, so under the default settings a transaction carrying
them is rejected with `txn-datacarrier-nonstandard`. This covers payloads
spread over three or more distinct P2WSH outputs that share one value of
546 sat or less (the OLGA layout, whatever magic it uses), taproot outputs
whose key is not on the curve, and any hash with one byte value repeated
eight times or six times in a row. Lightning anchor outputs, ordinals
postage and outputs to the all-zero burn hash are unaffected. The new
`-rejectfakeoutputs` option (default: 1, reset by `-corepolicy`) turns the
detection off without accepting other non-standard data carriers.
12 changes: 12 additions & 0 deletions doc/release-notes-dead-branch-data.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
Policy
------

- Data in a conditional part that constants make unreachable now counts as
data carrier bytes, so under the default settings a transaction carrying it
is rejected with `txn-datacarrier-nonstandard`. This already applied to the
`OP_FALSE OP_IF ... OP_ENDIF` inscription envelope; it now also covers
`OP_1 OP_NOTIF ... OP_ENDIF`, guards computed from constants, and the dead
part of an `OP_ELSE`. Scripts that branch on witness data or a signature
check, which is how spendable scripts branch, are unaffected. The new
`-rejectdeadbranches` option (default: 1, reset by `-corepolicy`) turns the
wider rule off and leaves only the envelope counted.
6 changes: 6 additions & 0 deletions src/init.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -721,7 +721,9 @@ void SetupServerArgs(ArgsManager& argsman, bool can_listen_ipc)
argsman.AddArg("-bytespersigopstrict", strprintf("Minimum bytes per sigop in transactions we relay and mine (default: %u)", DEFAULT_BYTES_PER_SIGOP_STRICT), ArgsManager::ALLOW_ANY, OptionsCategory::NODE_RELAY);
argsman.AddArg("-datacarrier", strprintf("Relay and mine data carrier transactions (default: %u)", DEFAULT_ACCEPT_DATACARRIER), ArgsManager::ALLOW_ANY, OptionsCategory::NODE_RELAY);
argsman.AddArg("-datacarriercost", strprintf("Treat extra data in transactions as at least N vbytes per actual byte (default: %s)", DEFAULT_WEIGHT_PER_DATA_BYTE / 4.0), ArgsManager::ALLOW_ANY, OptionsCategory::NODE_RELAY);
argsman.AddArg("-rejectdeadbranches", strprintf("Count data in a conditional branch that a pushed constant makes unreachable as data carrier bytes; the OP_FALSE OP_IF envelope is counted either way (default: %u)", DEFAULT_REJECT_DEAD_BRANCHES), ArgsManager::ALLOW_ANY, OptionsCategory::NODE_RELAY);
argsman.AddArg("-datacarrierfullcount", strprintf("Apply datacarriersize limit to all known datacarrier methods (default: %u)", DEFAULT_DATACARRIER_FULLCOUNT), ArgsManager::ALLOW_ANY | (DEFAULT_DATACARRIER_FULLCOUNT ? uint32_t{ArgsManager::DEBUG_ONLY} : 0), OptionsCategory::NODE_RELAY);
argsman.AddArg("-rejectfakeoutputs", strprintf("Count outputs whose hash or key is data as data carrier bytes (default: %u)", DEFAULT_REJECT_FAKE_OUTPUTS), ArgsManager::ALLOW_ANY, OptionsCategory::NODE_RELAY);
argsman.AddArg("-datacarriersize",
strprintf("Maximum size of data in data carrier transactions we relay and mine, in bytes (default: %u)",
MAX_OP_RETURN_RELAY),
Expand Down Expand Up @@ -870,9 +872,11 @@ void InitParameterInteraction(ArgsManager& args)
args.SoftSetArg("-permitbaremultisig", "1");
args.SoftSetArg("-rejectparasites", "0");
args.SoftSetArg("-rejecttokens", "0");
args.SoftSetArg("-rejectfakeoutputs", "0");
args.SoftSetArg("-subdustfeepenalty", "0");
args.SoftSetArg("-datacarriercost", "0.25");
args.SoftSetArg("-datacarrierfullcount", "0");
args.SoftSetArg("-rejectdeadbranches", "0");
args.SoftSetArg("-maxtxlegacysigops", strprintf("%s", std::numeric_limits<unsigned int>::max()));
args.SoftSetArg("-maxscriptsize", strprintf("%s", std::numeric_limits<unsigned int>::max()));
args.SoftSetArg("-mempooltruc", "enforce");
Expand Down Expand Up @@ -1213,6 +1217,8 @@ bool AppInitParameterInteraction(const ArgsManager& args)

g_script_size_policy_limit = args.GetIntArg("-maxscriptsize", g_script_size_policy_limit);

g_reject_dead_branches = args.GetBoolArg("-rejectdeadbranches", g_reject_dead_branches);

nBytesPerSigOp = args.GetIntArg("-bytespersigop", nBytesPerSigOp);
nBytesPerSigOpStrict = args.GetIntArg("-bytespersigopstrict", nBytesPerSigOpStrict);

Expand Down
1 change: 1 addition & 0 deletions src/kernel/mempool_options.h
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,7 @@ struct MemPoolOptions {
*/
std::optional<unsigned> max_datacarrier_bytes{DEFAULT_ACCEPT_DATACARRIER ? std::optional{MAX_OP_RETURN_RELAY} : std::nullopt};
bool datacarrier_fullcount{DEFAULT_DATACARRIER_FULLCOUNT};
bool reject_fake_outputs{DEFAULT_REJECT_FAKE_OUTPUTS};
bool permitbaredatacarrier{DEFAULT_PERMITBAREDATACARRIER};
bool permitbareanchor{DEFAULT_PERMITBAREANCHOR};
bool permit_bare_pubkey{DEFAULT_PERMIT_BAREPUBKEY};
Expand Down
2 changes: 2 additions & 0 deletions src/node/mempool_args.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -205,6 +205,8 @@ util::Result<void> ApplyArgsManOptions(const ArgsManager& argsman, const CChainP

mempool_opts.reject_tokens = argsman.GetBoolArg("-rejecttokens", DEFAULT_REJECT_TOKENS);

mempool_opts.reject_fake_outputs = argsman.GetBoolArg("-rejectfakeoutputs", DEFAULT_REJECT_FAKE_OUTPUTS);

mempool_opts.subdustfeepenalty = argsman.GetBoolArg("-subdustfeepenalty", DEFAULT_SUBDUSTFEEPENALTY);

if (argsman.GetBoolArg("-datacarrier", DEFAULT_ACCEPT_DATACARRIER)) {
Expand Down
2 changes: 1 addition & 1 deletion src/node/psbt.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -138,7 +138,7 @@ PSBTAnalysis AnalyzePSBT(PartiallySignedTransaction psbtx)

if (success) {
CTransaction ctx = CTransaction(mtx);
size_t size(GetVirtualTransactionSize(GetTransactionWeight(ctx) + CalculateExtraTxWeight(ctx, view, ::g_weight_per_data_byte), GetTransactionSigOpCost(ctx, view, STANDARD_SCRIPT_VERIFY_FLAGS), ::nBytesPerSigOp));
size_t size(GetVirtualTransactionSize(GetTransactionWeight(ctx) + CalculateExtraTxWeight(ctx, view, ::g_weight_per_data_byte, DataOutputBytes(ctx, &view)), GetTransactionSigOpCost(ctx, view, STANDARD_SCRIPT_VERIFY_FLAGS), ::nBytesPerSigOp));
result.estimated_vsize = size;
// Estimate fee rate
CFeeRate feerate(fee, size);
Expand Down
156 changes: 150 additions & 6 deletions src/policy/policy.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
#include <policy/feerate.h>
#include <policy/settings.h>
#include <primitives/transaction.h>
#include <pubkey.h>
#include <script/interpreter.h>
#include <script/script.h>
#include <script/solver.h>
Expand All @@ -27,12 +28,16 @@
#include <array>
#include <cstddef>
#include <limits>
#include <map>
#include <numeric>
#include <optional>
#include <set>
#include <string_view>
#include <utility>
#include <vector>

unsigned int g_script_size_policy_limit{DEFAULT_SCRIPT_SIZE_POLICY_LIMIT};
bool g_reject_dead_branches{DEFAULT_REJECT_DEAD_BRANCHES};

CAmount GetDustThreshold(const CTxOut& txout, const CFeeRate& dustRelayFeeIn)
{
Expand Down Expand Up @@ -633,28 +638,167 @@ std::pair<CScript, unsigned int> GetScriptForTransactionInput(CScript prevScript
return std::make_pair(CScript(), 0);
}

std::pair<size_t, size_t> DatacarrierBytes(const CTransaction& tx, const CCoinsViewCache& view)
namespace {

/** The hash or x-only key of a single-hash output type; empty for any other script. */
std::vector<unsigned char> OutputProgram(const CScript& script, TxoutType& type)
{
std::vector<std::vector<unsigned char>> solutions;
type = Solver(script, solutions);
switch (type) {
case TxoutType::PUBKEYHASH:
case TxoutType::SCRIPTHASH:
case TxoutType::WITNESS_V0_KEYHASH:
case TxoutType::WITNESS_V0_SCRIPTHASH:
case TxoutType::WITNESS_V1_TAPROOT:
return solutions[0];
default:
return {};
}
}

/**
* A hash or x-only key is uniformly random. One byte value turning up
* DATA_OUTPUT_BYTE_MULTIPLICITY times has probability 1.3e-10 in 32 random
* bytes and 1.7e-12 in 20; a run of DATA_OUTPUT_BYTE_RUN identical bytes
* 2.5e-11 and 1.4e-11. Length prefixes, headers, padding and plain text all
* trip one or the other. The all-zero hash is the conventional burn address
* and carries no payload, so it is left alone.
*/
bool ProgramLooksLikeData(const std::vector<unsigned char>& program)
{
if (std::all_of(program.begin(), program.end(), [](unsigned char b) { return b == 0; })) return false;
unsigned int counts[256]{};
unsigned int run{0};
unsigned char last{0};
for (const unsigned char b : program) {
if (++counts[b] >= DATA_OUTPUT_BYTE_MULTIPLICITY) return true;
run = (run && b == last) ? run + 1 : 1;
if (run >= DATA_OUTPUT_BYTE_RUN) return true;
last = b;
}
return false;
}

} // namespace

std::vector<size_t> DataOutputBytes(const CTransaction& tx, const CCoinsViewCache* view)
{
const size_t n{tx.vout.size()};
// Outputs that pay the script of a coin this transaction spends, such as change
// back to a vanity address; looked up by script without copying any
std::vector<bool> spent(n, false);
if (view && n) {
std::vector<size_t> by_script(n);
std::iota(by_script.begin(), by_script.end(), 0);
const auto script_order = [&tx](size_t a, size_t b) { return tx.vout[a].scriptPubKey < tx.vout[b].scriptPubKey; };
std::sort(by_script.begin(), by_script.end(), script_order);
for (const CTxIn& txin : tx.vin) {
const Coin& coin{view->AccessCoin(txin.prevout)};
if (coin.IsSpent()) continue;
const CScript& script{coin.out.scriptPubKey};
auto it{std::lower_bound(by_script.begin(), by_script.end(), script, [&tx](size_t a, const CScript& s) { return tx.vout[a].scriptPubKey < s; })};
// The outputs paying one script sit together and are marked together, so
// a script already seen is skipped: each output is visited at most once
if (it == by_script.end() || spent[*it]) continue;
for (; it != by_script.end() && tx.vout[*it].scriptPubKey == script; ++it) spent[*it] = true;
}
}
std::vector<size_t> program_size(n, 0);
std::vector<TxoutType> types(n, TxoutType::NONSTANDARD);
std::vector<bool> in_run(n, false);
std::vector<bool> data(n, false);
std::vector<bool> covered(n, false);
std::map<CAmount, std::set<std::vector<unsigned char>>> run_programs;
unsigned int curve_checks{0};

for (size_t i{0}; i < n; ++i) {
const CTxOut& txout{tx.vout[i]};
if (covered[i]) continue;
if (const size_t olga_bytes{txout.scriptPubKey.IsOLGA(n - i)}; olga_bytes) {
// The OLGA detector counts this header and its chunks already
const size_t olga_outputs{olga_bytes / (WITNESS_V0_SCRIPTHASH_SIZE + /* script length */ 1 + /* amount */ 8)};
for (size_t j{i}; j < std::min(n, i + olga_outputs); ++j) covered[j] = true;
continue;
}
const auto program{OutputProgram(txout.scriptPubKey, types[i])};
if (program.empty()) continue;
program_size[i] = program.size();
if (spent[i]) {
// A real hash or key, whatever it looks like
} else if (ProgramLooksLikeData(program)) {
data[i] = true;
} else if (types[i] == TxoutType::WITNESS_V1_TAPROOT && curve_checks < DATA_OUTPUT_CURVE_CHECKS) {
// Not a point on the curve: nobody can ever spend this. The check
// costs a field square root, so only the first
// DATA_OUTPUT_CURVE_CHECKS taproot outputs get it. A whitened
// chunk is off the curve half the time, so a payload among them
// trips this and the sibling rule marks the rest of its chunks.
++curve_checks;
if (!XOnlyPubKey{program}.IsFullyValid()) data[i] = true;
}
if (types[i] == TxoutType::WITNESS_V0_SCRIPTHASH && txout.nValue <= DATA_OUTPUT_RUN_MAX_VALUE) {
in_run[i] = true;
run_programs[txout.nValue].insert(program);
}
}

// A payload is spread over several outputs, and its other chunks may be
// whitened, so anything caught marks its siblings of the same type and
// value. Separately, DATA_OUTPUT_DUST_RUN or more distinct P2WSH outputs
// sharing one small value are data on their own: encoders pay every chunk
// the same, Lightning anchors come in pairs and HTLC amounts differ, and
// padding repeats one script. Taproot is left to the curve check, since a
// run of small taproot outputs is ordinals postage, not a payload.
std::set<std::pair<TxoutType, CAmount>> data_groups;
for (size_t i{0}; i < n; ++i) {
if (data[i]) data_groups.emplace(types[i], tx.vout[i].nValue);
}
std::vector<size_t> ret(n, 0);
for (size_t i{0}; i < n; ++i) {
if (!program_size[i] || spent[i]) continue;
const bool sibling{data_groups.count({types[i], tx.vout[i].nValue}) > 0};
const bool dust_run{in_run[i] && run_programs[tx.vout[i].nValue].size() >= DATA_OUTPUT_DUST_RUN};
if (data[i] || sibling || dust_run) {
ret[i] = program_size[i] + /* script length */ 1 + /* amount */ 8;
}
}
return ret;
}

/**
* Whether to look for dead branches in this input. A witness over the script size
* limit fails IsWitnessStandard right after, so following its constants would be
* work spent on a transaction that is rejected anyway.
*/
static bool FollowDeadBranches(const CTxIn& txin)
{
return ::g_reject_dead_branches && GetSerializeSize(txin.scriptWitness.stack) <= g_script_size_policy_limit;
}

std::pair<size_t, size_t> DatacarrierBytes(const CTransaction& tx, const CCoinsViewCache& view, const std::vector<size_t>& data_outputs)
{
std::pair<size_t, size_t> ret{0, 0};

for (const CTxIn& txin : tx.vin) {
const CTxOut &utxo = view.AccessCoin(txin.prevout).out;
auto[script, consensus_weight_per_byte] = GetScriptForTransactionInput(utxo.scriptPubKey, txin);
const auto dcb = script.DatacarrierBytes(0, &txin.scriptWitness);
const auto dcb = script.DatacarrierBytes(0, &txin.scriptWitness, FollowDeadBranches(txin));
ret.first += dcb.first;
ret.second += dcb.second;
}
for (size_t i{tx.vout.size()}; i; ) {
const CTxOut& txout = tx.vout[--i];
const auto dcb = txout.scriptPubKey.DatacarrierBytes(tx.vout.size() - i);
ret.first += dcb.first;
ret.second += dcb.second;
// An OLGA header counts its whole payload, so keep the larger figure
ret.second += std::max(dcb.second, data_outputs[i]);
}

return ret;
}

int32_t CalculateExtraTxWeight(const CTransaction& tx, const CCoinsViewCache& view, const unsigned int weight_per_data_byte)
int32_t CalculateExtraTxWeight(const CTransaction& tx, const CCoinsViewCache& view, const unsigned int weight_per_data_byte, const std::vector<size_t>& data_outputs)
{
int64_t mod_weight{0};

Expand All @@ -664,15 +808,15 @@ int32_t CalculateExtraTxWeight(const CTransaction& tx, const CCoinsViewCache& vi
const CTxOut &utxo = view.AccessCoin(txin.prevout).out;
auto[script, consensus_weight_per_byte] = GetScriptForTransactionInput(utxo.scriptPubKey, txin);
if (weight_per_data_byte > consensus_weight_per_byte) {
const auto dcb = script.DatacarrierBytes(0, &txin.scriptWitness);
const auto dcb = script.DatacarrierBytes(0, &txin.scriptWitness, FollowDeadBranches(txin));
mod_weight += int64_t(dcb.first + dcb.second) * (weight_per_data_byte - consensus_weight_per_byte);
}
}
if (weight_per_data_byte > WITNESS_SCALE_FACTOR) {
for (size_t i{tx.vout.size()}; i; ) {
const CTxOut& txout = tx.vout[--i];
const auto dcb = txout.scriptPubKey.DatacarrierBytes(tx.vout.size() - i);
mod_weight += int64_t(dcb.first + dcb.second) * (weight_per_data_byte - WITNESS_SCALE_FACTOR);
mod_weight += int64_t(std::max(dcb.first + dcb.second, data_outputs[i])) * (weight_per_data_byte - WITNESS_SCALE_FACTOR);
}
}
}
Expand Down
31 changes: 29 additions & 2 deletions src/policy/policy.h
Original file line number Diff line number Diff line change
Expand Up @@ -64,8 +64,12 @@ static constexpr unsigned int DEFAULT_BYTES_PER_SIGOP{20};
static constexpr unsigned int DEFAULT_BYTES_PER_SIGOP_STRICT{20};
/** Default for -datacarriercost (multiplied by WITNESS_SCALE_FACTOR) */
static constexpr unsigned int DEFAULT_WEIGHT_PER_DATA_BYTE{4};
/** Default for -rejectdeadbranches */
static constexpr bool DEFAULT_REJECT_DEAD_BRANCHES{true};
/** Default for -rejecttokens */
static constexpr bool DEFAULT_REJECT_TOKENS{true};
/** Default for -rejectfakeoutputs */
static constexpr bool DEFAULT_REJECT_FAKE_OUTPUTS{true};
/** Default for -subdustfeepenalty */
static constexpr bool DEFAULT_SUBDUSTFEEPENALTY{true};

Expand Down Expand Up @@ -240,8 +244,31 @@ static inline int64_t GetVirtualTransactionInputSize(const CTxIn& tx)

std::pair<CScript, unsigned int> GetScriptForTransactionInput(CScript prevScript, const CTxIn&);

std::pair<size_t, size_t> DatacarrierBytes(const CTransaction& tx, const CCoinsViewCache& view);
/** A byte value seen this many times in one hash or key marks the output as data */
static constexpr unsigned int DATA_OUTPUT_BYTE_MULTIPLICITY{8};
/** A run of identical bytes this long in one hash or key marks the output as data */
static constexpr unsigned int DATA_OUTPUT_BYTE_RUN{6};
/** Distinct P2WSH outputs sharing one value of at most DATA_OUTPUT_RUN_MAX_VALUE are data from this many up */
static constexpr unsigned int DATA_OUTPUT_DUST_RUN{3};
/** Highest output value a data run is looked for at; batch payouts appear from 600 sat up */
static constexpr CAmount DATA_OUTPUT_RUN_MAX_VALUE{546};
/** Taproot outputs per transaction whose key is checked to be on the curve */
static constexpr unsigned int DATA_OUTPUT_CURVE_CHECKS{64};

int32_t CalculateExtraTxWeight(const CTransaction& tx, const CCoinsViewCache& view, const unsigned int weight_per_data_byte);
/**
* Find outputs whose hash or key field carries data rather than a hash or key.
* Returns, per output, the bytes it spends on that data (program, script
* length and amount, as counted for OLGA), or 0 for a real output or one the
* OLGA detector already counts. With a view, an output whose script is the
* script of a coin the transaction spends is real and never counted: the
* spender can spend that coin, and a chosen hash or key costs at least 2^128
* work (a taproot key's discrete log), so the most it can carry is a ground
* prefix such as a vanity address.
*/
std::vector<size_t> DataOutputBytes(const CTransaction& tx, const CCoinsViewCache* view = nullptr);

std::pair<size_t, size_t> DatacarrierBytes(const CTransaction& tx, const CCoinsViewCache& view, const std::vector<size_t>& data_outputs);

int32_t CalculateExtraTxWeight(const CTransaction& tx, const CCoinsViewCache& view, const unsigned int weight_per_data_byte, const std::vector<size_t>& data_outputs);

#endif // BITCOIN_POLICY_POLICY_H
1 change: 1 addition & 0 deletions src/policy/settings.h
Original file line number Diff line number Diff line change
Expand Up @@ -10,5 +10,6 @@ extern unsigned int g_script_size_policy_limit;
extern unsigned int nBytesPerSigOp;
extern unsigned int nBytesPerSigOpStrict;
extern unsigned int g_weight_per_data_byte;
extern bool g_reject_dead_branches;

#endif // BITCOIN_POLICY_SETTINGS_H
Loading