From 3e494e5a94368e1ac900509019c294731efb1a0b Mon Sep 17 00:00:00 2001 From: connorslab <82235496+connorslab@users.noreply.github.com> Date: Wed, 7 Oct 2026 18:51:26 -0600 Subject: [PATCH] policy: Charge a fee penalty for 1-of-N witness keys --- src/init.cpp | 3 + src/policy/policy.cpp | 32 +++ src/policy/policy.h | 8 + src/policy/settings.h | 1 + src/test/transaction_tests.cpp | 37 ++++ src/validation.cpp | 13 ++ src/wallet/spend.cpp | 62 +++++- test/functional/mempool_oneofn_cost.py | 266 +++++++++++++++++++++++++ test/functional/test_runner.py | 1 + 9 files changed, 414 insertions(+), 9 deletions(-) create mode 100644 test/functional/mempool_oneofn_cost.py diff --git a/src/init.cpp b/src/init.cpp index 50c4d691bf67..7cbdf6857082 100644 --- a/src/init.cpp +++ b/src/init.cpp @@ -725,6 +725,7 @@ 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("-oneofnkeypenalty", strprintf("Reduce modified fees by the minrelaytxfee charge for the witness discount on all but one public key in exact 1-of-N multisig scripts. Also affects legitimate matching wallet spends; wallet fee estimates include a conservative allowance (default: %u)", DEFAULT_ONE_OF_N_KEY_PENALTY), 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("-datacarriersize", strprintf("Maximum size of data in data carrier transactions we relay and mine, in bytes (maximum %s, default: %u)", @@ -877,6 +878,7 @@ void InitParameterInteraction(ArgsManager& args) args.SoftSetArg("-rejecttokens", "0"); args.SoftSetArg("-subdustfeepenalty", "0"); args.SoftSetArg("-datacarriercost", "0.25"); + args.SoftSetBoolArg("-oneofnkeypenalty", false); args.SoftSetArg("-datacarrierfullcount", "0"); args.SoftSetArg("-maxtxlegacysigops", strprintf("%s", std::numeric_limits::max())); args.SoftSetArg("-maxscriptsize", strprintf("%s", std::numeric_limits::max())); @@ -1222,6 +1224,7 @@ bool AppInitParameterInteraction(const ArgsManager& args) g_script_size_policy_limit = args.GetIntArg("-maxscriptsize", g_script_size_policy_limit); nBytesPerSigOp = args.GetIntArg("-bytespersigop", nBytesPerSigOp); + g_one_of_n_key_penalty = args.GetBoolArg("-oneofnkeypenalty", DEFAULT_ONE_OF_N_KEY_PENALTY); nBytesPerSigOpStrict = args.GetIntArg("-bytespersigopstrict", nBytesPerSigOpStrict); if (!g_wallet_init_interface.ParameterInteraction()) return false; diff --git a/src/policy/policy.cpp b/src/policy/policy.cpp index 679c0c967a1f..06a2f46bb3d8 100644 --- a/src/policy/policy.cpp +++ b/src/policy/policy.cpp @@ -33,6 +33,7 @@ #include unsigned int g_script_size_policy_limit{DEFAULT_SCRIPT_SIZE_POLICY_LIMIT}; +bool g_one_of_n_key_penalty{DEFAULT_ONE_OF_N_KEY_PENALTY}; CAmount GetDustThreshold(const CTxOut& txout, const CFeeRate& dustRelayFeeIn) { @@ -654,6 +655,37 @@ std::pair DatacarrierBytes(const CTransaction& tx, const CCoinsV return ret; } +int64_t GetOneOfNExtraWeight(const CScript& script) +{ + // Match whole scripts, not fragments of recovery paths or other conditions. + // These keys may all belong to a wallet; this does not classify them as data. + std::vector> solutions; + if (Solver(script, solutions) == TxoutType::MULTISIG && solutions.front()[0] == 1) { + size_t key_bytes{0}, largest_key{0}; + for (size_t i{1}; i + 1 < solutions.size(); ++i) { + key_bytes += solutions[i].size(); + largest_key = std::max(largest_key, solutions[i].size()); + } + return int64_t(key_bytes - largest_key) * (WITNESS_SCALE_FACTOR - 1); + } + if (const auto multi_a = MatchMultiA(script); multi_a && multi_a->first == 1) { + return int64_t(multi_a->second.size() - 1) * 32 * (WITNESS_SCALE_FACTOR - 1); + } + return 0; +} + +int64_t GetOneOfNTxExtraWeight(const CTransaction& tx, const CCoinsViewCache& view) +{ + int64_t extra_weight{0}; + for (const CTxIn& txin : tx.vin) { + if (txin.scriptWitness.IsNull()) continue; + const auto& prev_script = view.AccessCoin(txin.prevout).out.scriptPubKey; + const auto [script, weight_per_byte] = GetScriptForTransactionInput(prev_script, txin); + if (weight_per_byte == 1) extra_weight += GetOneOfNExtraWeight(script); + } + return extra_weight; +} + int32_t CalculateExtraTxWeight(const CTransaction& tx, const CCoinsViewCache& view, const unsigned int weight_per_data_byte) { int64_t mod_weight{0}; diff --git a/src/policy/policy.h b/src/policy/policy.h index 954c79f4bd17..d9e95428a44d 100644 --- a/src/policy/policy.h +++ b/src/policy/policy.h @@ -64,6 +64,8 @@ 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 -oneofnkeypenalty */ +static constexpr bool DEFAULT_ONE_OF_N_KEY_PENALTY{true}; /** Default for -rejecttokens */ static constexpr bool DEFAULT_REJECT_TOKENS{true}; /** Default for -subdustfeepenalty */ @@ -243,6 +245,12 @@ std::pair GetScriptForTransactionInput(CScript prevScript std::pair DatacarrierBytes(const CTransaction& tx, const CCoinsViewCache& view); +/** Policy weight needed to price all but one key in an exact 1-of-N witness script at one vbyte per byte. */ +int64_t GetOneOfNExtraWeight(const CScript& script); + +/** Additional key weight across witness inputs, used only to calculate the fee penalty. */ +int64_t GetOneOfNTxExtraWeight(const CTransaction& tx, const CCoinsViewCache& view); + int32_t CalculateExtraTxWeight(const CTransaction& tx, const CCoinsViewCache& view, const unsigned int weight_per_data_byte); #endif // BITCOIN_POLICY_POLICY_H diff --git a/src/policy/settings.h b/src/policy/settings.h index 03cfc3775da1..b254b4715510 100644 --- a/src/policy/settings.h +++ b/src/policy/settings.h @@ -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_one_of_n_key_penalty; #endif // BITCOIN_POLICY_SETTINGS_H diff --git a/src/test/transaction_tests.cpp b/src/test/transaction_tests.cpp index f9066a30df8c..ad17f52d3a73 100644 --- a/src/test/transaction_tests.cpp +++ b/src/test/transaction_tests.cpp @@ -1423,4 +1423,41 @@ BOOST_AUTO_TEST_CASE(spends_witness_prog) } } +BOOST_AUTO_TEST_CASE(oneofn_policy_weight) +{ + const auto pubkey = GenerateRandomKey().GetPubKey(); + const auto key = ToByteVector(pubkey); + const auto xonly = ToByteVector(XOnlyPubKey{pubkey}); + for (const int n : {1, 2, 3, 4, 15, 20}) { + for (const int threshold : {1, n}) { + CScript multi; + multi << threshold; + for (int i{0}; i < n; ++i) multi << key; + multi << n << OP_CHECKMULTISIG; + const int64_t expected = threshold == 1 ? (n - 1) * 33 * 3 : 0; + BOOST_CHECK_EQUAL(GetOneOfNExtraWeight(multi), expected); + + // Do not infer thresholds inside more complex scripts. + CScript prefixed = CScript{} << OP_0 << OP_CHECKSEQUENCEVERIFY << OP_DROP; + prefixed.insert(prefixed.end(), multi.begin(), multi.end()); + BOOST_CHECK_EQUAL(GetOneOfNExtraWeight(prefixed), 0); + BOOST_CHECK_EQUAL(GetOneOfNExtraWeight(CScript{multi} << OP_NOP), 0); + } + } + for (const int n : {1, 2, 3, 4, 15, 999}) { + for (const int threshold : {1, n}) { + CScript multi_a; + for (int i{0}; i < n; ++i) multi_a << xonly << (i == 0 ? OP_CHECKSIG : OP_CHECKSIGADD); + multi_a << threshold << OP_NUMEQUAL; + BOOST_CHECK_EQUAL(GetOneOfNExtraWeight(multi_a), threshold == 1 ? (n - 1) * 32 * 3 : 0); + BOOST_CHECK_EQUAL(GetOneOfNExtraWeight(CScript{multi_a} << OP_NOP), 0); + multi_a.pop_back(); + BOOST_CHECK_EQUAL(GetOneOfNExtraWeight(multi_a), 0); + } + } + BOOST_CHECK_EQUAL(GetOneOfNExtraWeight(CScript{}), 0); + BOOST_CHECK_EQUAL(GetOneOfNExtraWeight(CScript{} << xonly << OP_CHECKSIG), 0); + BOOST_CHECK_EQUAL(GetOneOfNExtraWeight(CScript{} << OP_PUSHDATA1), 0); +} + BOOST_AUTO_TEST_SUITE_END() diff --git a/src/validation.cpp b/src/validation.cpp index 26661fbee5aa..c02f426b693b 100644 --- a/src/validation.cpp +++ b/src/validation.cpp @@ -1139,6 +1139,19 @@ bool MemPoolAccept::PreChecks(ATMPArgs& args, Workspace& ws) } } + // Apply after the ephemeral zero-fee check: this automatic penalty must not + // turn a zero-fee parent into a nonzero-fee transaction for that check. + // Keep size accounting unchanged, in particular TRUC and ancestor limits. + if (g_one_of_n_key_penalty) { + const auto penalty = m_pool.m_opts.min_relay_feerate.GetFee(GetVirtualTransactionSize(GetOneOfNTxExtraWeight(tx, m_view), 0, 0)); + if (penalty > 0) { + m_subpackage.m_changeset->m_to_add.modify(ws.m_tx_handle, [&](CTxMemPoolEntry& e) { + e.UpdateModifiedFee(-penalty); + }); + ws.m_modified_fees = SaturatingAdd(ws.m_modified_fees, -penalty); + } + } + // To avoid rejecting low-sigop bare-multisig transactions, the sigops // are counted a second time more accurately. if ((nSigOpsCost > MAX_STANDARD_TX_SIGOPS_COST) || (nBytesPerSigOpStrict && GetAccurateTransactionSigOpCost(tx, m_view, STANDARD_SCRIPT_VERIFY_FLAGS) > ws.m_vsize * WITNESS_SCALE_FACTOR / nBytesPerSigOpStrict)) { diff --git a/src/wallet/spend.cpp b/src/wallet/spend.cpp index e0344be525aa..bad05e214662 100644 --- a/src/wallet/spend.cpp +++ b/src/wallet/spend.cpp @@ -8,10 +8,12 @@ #include #include #include +#include #include #include #include #include +#include #include #include