From 092c1f4d288cc6ce95e6577f9e74e3f2a511ad6f Mon Sep 17 00:00:00 2001 From: Greg Sanders Date: Fri, 19 Jul 2024 12:25:23 -0400 Subject: [PATCH] policy: Allow dust in transactions, spent in-mempool Also known as Ephemeral Dust. We try to ensure that dust is spent in blocks by requiring: - ephemeral dust tx is 0-fee child bringing fees) - ephemeral dust tx only has one dust output - the output is spent by a single child transaction 0-fee requirement means there is no incentive to mine a transaction which doesn't have a child bringing its own fees for the transaction package. --- src/Makefile.am | 4 + src/policy/ephemeral_policy.cpp | 126 ++++++++++++++++++++++++++++ src/policy/ephemeral_policy.h | 57 +++++++++++++ src/validation.cpp | 63 ++++++++++++-- test/functional/data/invalid_txs.py | 2 +- 5 files changed, 246 insertions(+), 6 deletions(-) create mode 100644 src/policy/ephemeral_policy.cpp create mode 100644 src/policy/ephemeral_policy.h diff --git a/src/Makefile.am b/src/Makefile.am index 72dd942c4012d6..ae27eb229311d4 100644 --- a/src/Makefile.am +++ b/src/Makefile.am @@ -242,6 +242,7 @@ BITCOIN_CORE_H = \ node/warnings.h \ noui.h \ outputtype.h \ + policy/ephemeral_policy.h \ policy/feerate.h \ policy/fees.h \ policy/fees_args.h \ @@ -445,6 +446,7 @@ libbitcoin_node_a_SOURCES = \ node/utxo_snapshot.cpp \ node/warnings.cpp \ noui.cpp \ + policy/ephemeral_policy.cpp \ policy/fees.cpp \ policy/fees_args.cpp \ policy/packages.cpp \ @@ -705,6 +707,7 @@ libbitcoin_common_a_SOURCES = \ netbase.cpp \ net_permissions.cpp \ outputtype.cpp \ + policy/ephemeral_policy.cpp \ policy/feerate.cpp \ policy/policy.cpp \ protocol.cpp \ @@ -951,6 +954,7 @@ libbitcoinkernel_la_SOURCES = \ node/blockstorage.cpp \ node/chainstate.cpp \ node/utxo_snapshot.cpp \ + policy/ephemeral_policy.cpp \ policy/feerate.cpp \ policy/packages.cpp \ policy/policy.cpp \ diff --git a/src/policy/ephemeral_policy.cpp b/src/policy/ephemeral_policy.cpp new file mode 100644 index 00000000000000..280e7a19b0bdc5 --- /dev/null +++ b/src/policy/ephemeral_policy.cpp @@ -0,0 +1,126 @@ +// Copyright (c) 2024-present The Bitcoin Core developers +// Distributed under the MIT software license, see the accompanying +// file COPYING or http://www.opensource.org/licenses/mit-license.php. + +#include +#include + +bool CheckValidEphemeralTx(const CTransaction& tx, CFeeRate dust_relay_fee, CAmount txfee, TxValidationState& state) +{ + bool has_dust = false; + for (const CTxOut& txout : tx.vout) { + if (IsDust(txout, dust_relay_fee)) { + // We only allow a single dusty output + if (has_dust) { + return state.Invalid(TxValidationResult::TX_NOT_STANDARD, "dust"); + } + has_dust = true; + } + } + + // No dust; it's complete standard already + if (!has_dust) return true; + + // We never want to give incentives to mine this alone + if (txfee != 0) { + return state.Invalid(TxValidationResult::TX_NOT_STANDARD, "dust"); + } + + return true; +} + +std::optional CheckEphemeralSpends(const Package& package, CFeeRate dust_relay_rate) +{ + // Package is topologically sorted, and PreChecks ensures that + // there is up to one dust output per tx. + + assert(std::all_of(package.cbegin(), package.cend(), [](const auto& tx){return tx != nullptr;})); + + // Running tally of unspent dust + std::unordered_set unspent_dust; + + // If a parent tx has dust, we have to check for the spend + // Single dust per tx possible + std::map map_tx_dust; + + for (const auto& tx : package) { + std::unordered_set child_unspent_dust; + for (const auto& tx_input : tx->vin) { + // Parent tx had dust, child MUST be sweeping it + // if it's spending any output from parent + if (map_tx_dust.contains(tx_input.prevout.hash)) { + child_unspent_dust.insert(tx_input.prevout.hash); + } + } + + // Now that we've built a list of parent txids + // that have dust, make sure that all parent's + // dust are swept by this same tx + for (const auto& tx_input : tx->vin) { + const auto& prevout = tx_input.prevout; + // Parent tx had dust, child MUST be sweeping it + // if it's spending any output from parent + if (map_tx_dust.contains(prevout.hash) && + map_tx_dust[prevout.hash] == prevout.n) { + child_unspent_dust.erase(prevout.hash); + } + + // We want to detect dangling dust too + unspent_dust.erase(tx_input.prevout); + } + + if (!child_unspent_dust.empty()) { + return tx->GetHash(); + } + + // Process new dust + for (uint32_t i=0; ivout.size(); i++) { + if (IsDust(tx->vout[i], dust_relay_rate)) { + // CheckValidEphemeralTx should disallow multiples + Assume(!map_tx_dust.contains(tx->GetHash())); + map_tx_dust[tx->GetHash()] = i; + unspent_dust.insert(COutPoint(tx->GetHash(), i)); + } + } + + } + + if (!unspent_dust.empty()) { + return unspent_dust.begin()->hash; + } + + return std::nullopt; +} + +std::optional CheckEphemeralSpends(const CTransactionRef& ptx, + const CTxMemPool::setEntries& ancestors, + CFeeRate dust_relay_feerate) +{ + std::unordered_set unspent_dust; + + std::unordered_set parents; + for (const auto& tx_input : ptx->vin) { + parents.insert(tx_input.prevout.hash); + } + + for (const auto& entry : ancestors) { + const auto& tx = entry->GetTx(); + // Only deal with direct parents + if (parents.count(tx.GetHash()) == 0) continue; + for (uint32_t i=0; ivin) { + unspent_dust.erase(input.prevout); + } + + if (!unspent_dust.empty()) { + return strprintf("tx does not spend parent ephemeral dust"); + } + + return std::nullopt; +} diff --git a/src/policy/ephemeral_policy.h b/src/policy/ephemeral_policy.h new file mode 100644 index 00000000000000..a21d2a1e4110b4 --- /dev/null +++ b/src/policy/ephemeral_policy.h @@ -0,0 +1,57 @@ +// Copyright (c) 2024-present The Bitcoin Core developers +// Distributed under the MIT software license, see the accompanying +// file COPYING or http://www.opensource.org/licenses/mit-license.php. + +#ifndef BITCOIN_POLICY_EPHEMERAL_POLICY_H +#define BITCOIN_POLICY_EPHEMERAL_POLICY_H + +#include +#include +#include +#include + +/** These utility functions ensure that ephemeral dust is safely + * created and spent without risking them entering the utxo + * set. + + * This is ensured by requiring: + * - CheckValidEphemeralTx checks are respected + * - The parent has no child (and 0-fee as implied above to disincentivize mining) + * - OR the parent transaction has exactly one child, and the dust is spent by that child + * + * Imagine three transactions: + * TxA, 0-fee with two outputs, one non-dust, one dust + * TxB, spends TxA's non-dust + * TxC, spends TxA's dust + * + * All the dust is spent if TxA+TxB+TxC is accepted, but the mining template may just pick + * up TxA+TxB rather than the three "legal configurations: + * 1) None + * 2) TxA+TxB+TxC + * 3) TxA+TxC + * By requiring the child transaction to sweep any dust from the parent txn, we ensure that + * there is a single child only, and this child is the only transaction possible for + * bringing fees, or itself being spent by another child, and so on. + */ + +/** Does context-less checks about a single transaction. + * If it has relay dust, it returns false if any are true: + * - tx has non-0 fee + - tx has more than one dust output + * and sets relevant invalid state. + * Otherwise it returns true. + */ +bool CheckValidEphemeralTx(const CTransaction& tx, CFeeRate dust_relay_fee, CAmount txfee, TxValidationState& state); + +/** Checks that all dust in a package ends up spent by an only-child. Assumes package is well-formed and sorted. + * The function returns std::nullopt if all dust is properly spent, or the txid of a violated ephemeral transaction. + */ +std::optional CheckEphemeralSpends(const Package& package, CFeeRate dust_relay_rate); + +/** Checks that individual transactions' parents have all their dust spent by this only-child transaction. + */ +std::optional CheckEphemeralSpends(const CTransactionRef& ptx, + const CTxMemPool::setEntries& ancestors, + CFeeRate dust_relay_feerate); + +#endif // BITCOIN_POLICY_EPHEMERAL_POLICY_H diff --git a/src/validation.cpp b/src/validation.cpp index 2b8f64e81afe20..24b0db3985c536 100644 --- a/src/validation.cpp +++ b/src/validation.cpp @@ -32,6 +32,7 @@ #include #include #include +#include #include #include #include @@ -484,6 +485,11 @@ class MemPoolAccept /** Whether CPFP carveout and RBF carveout are granted. */ const bool m_allow_carveouts; + /** Whether we allow dust inside PreChecks, since spentness checks will be handled + * later in AcceptMultipleTransactions. + */ + const bool m_allow_ephemeral_dust; + /** Parameters for single transaction mempool validation. */ static ATMPArgs SingleAccept(const CChainParams& chainparams, int64_t accept_time, bool bypass_limits, std::vector& coins_to_uncache, @@ -499,6 +505,7 @@ class MemPoolAccept /* m_package_feerates */ false, /* m_client_maxfeerate */ {}, // checked by caller /* m_allow_carveouts */ true, + /* m_allow_ephemeral_dust */ false, }; } @@ -516,6 +523,7 @@ class MemPoolAccept /* m_package_feerates */ false, /* m_client_maxfeerate */ {}, // checked by caller /* m_allow_carveouts */ false, + /* m_allow_ephemeral_dust */ false, }; } @@ -533,6 +541,7 @@ class MemPoolAccept /* m_package_feerates */ true, /* m_client_maxfeerate */ client_maxfeerate, /* m_allow_carveouts */ false, + /* m_allow_ephemeral_dust */ true, }; } @@ -549,6 +558,7 @@ class MemPoolAccept /* m_package_feerates */ false, // only 1 transaction /* m_client_maxfeerate */ package_args.m_client_maxfeerate, /* m_allow_carveouts */ false, + /* m_allow_ephemeral_dust */ false, }; } @@ -565,7 +575,8 @@ class MemPoolAccept bool package_submission, bool package_feerates, std::optional client_maxfeerate, - bool allow_carveouts) + bool allow_carveouts, + bool allow_epehemeral_dust) : m_chainparams{chainparams}, m_accept_time{accept_time}, m_bypass_limits{bypass_limits}, @@ -576,7 +587,8 @@ class MemPoolAccept m_package_submission{package_submission}, m_package_feerates{package_feerates}, m_client_maxfeerate{client_maxfeerate}, - m_allow_carveouts{allow_carveouts} + m_allow_carveouts{allow_carveouts}, + m_allow_ephemeral_dust{allow_epehemeral_dust} { // If we are using package feerates, we must be doing package submission. // It also means carveouts and sibling eviction are not permitted. @@ -584,6 +596,7 @@ class MemPoolAccept Assume(m_package_submission); Assume(!m_allow_carveouts); Assume(!m_allow_sibling_eviction); + Assume(m_allow_ephemeral_dust); } if (m_allow_sibling_eviction) Assume(m_allow_replacement); } @@ -792,9 +805,12 @@ bool MemPoolAccept::PreChecks(ATMPArgs& args, Workspace& ws) return state.Invalid(TxValidationResult::TX_CONSENSUS, "coinbase"); // Rather not work on nonstandard transactions (unless -testnet/-regtest) - std::string reason; - if (m_pool.m_opts.require_standard && !IsStandardTx(tx, m_pool.m_opts.max_datacarrier_bytes, m_pool.m_opts.permit_bare_multisig, m_pool.m_opts.dust_relay_feerate, reason)) { - return state.Invalid(TxValidationResult::TX_NOT_STANDARD, reason); + std::string std_reason; + if (m_pool.m_opts.require_standard && + !IsStandardTx(tx, m_pool.m_opts.max_datacarrier_bytes, m_pool.m_opts.permit_bare_multisig, /*dust_relay_fee=*/CFeeRate(0), std_reason)) { + // Dust checks completed later + Assume(std_reason != "dust"); + return state.Invalid(TxValidationResult::TX_NOT_STANDARD, std_reason); } // Transactions smaller than 65 non-witness bytes are not relayed to mitigate CVE-2017-12842. @@ -934,6 +950,23 @@ bool MemPoolAccept::PreChecks(ATMPArgs& args, Workspace& ws) fSpendsCoinbase, nSigOpsCost, lock_points.value())); ws.m_vsize = entry->GetTxSize(); + // Finalize dust checks at individual tx level + if (m_pool.m_opts.require_standard) { + + // Dust was detected, but tx not valid format for ephemeral dust + if (!CheckValidEphemeralTx(tx, m_pool.m_opts.dust_relay_feerate, ws.m_base_fees, state)) { + return false; // state filled in by CheckValidEphemeralTx + } + + // If there is an otherwise valid ephemeral dust, return TX_RECONSIDERABLE to allow retries in a package + if (!args.m_allow_ephemeral_dust && + !bypass_limits && + !IsStandardTx(tx, m_pool.m_opts.max_datacarrier_bytes, m_pool.m_opts.permit_bare_multisig, m_pool.m_opts.dust_relay_feerate, std_reason)) { + Assume(std_reason == "dust"); + return state.Invalid(TxValidationResult::TX_RECONSIDERABLE, std_reason); + } + } + if (nSigOpsCost > MAX_STANDARD_TX_SIGOPS_COST) return state.Invalid(TxValidationResult::TX_NOT_STANDARD, "bad-txns-too-many-sigops", strprintf("%d", nSigOpsCost)); @@ -1062,6 +1095,13 @@ bool MemPoolAccept::PreChecks(ATMPArgs& args, Workspace& ws) } } + // Ensure any parents in-mempool that have dust have it spent by this transaction + if (!bypass_limits && m_pool.m_opts.require_standard) { + if (auto err_string{CheckEphemeralSpends(ws.m_ptx, ws.m_ancestors, m_pool.m_opts.dust_relay_feerate)}) { + return state.Invalid(TxValidationResult::TX_MEMPOOL_POLICY, "ephemeral-dust-unspent", *err_string); + } + } + // A transaction that spends outputs that would be replaced by it is invalid. Now // that we have the set of all ancestors we can detect this // pathological case by making sure ws.m_conflicts and ws.m_ancestors don't @@ -1560,6 +1600,19 @@ PackageMempoolAcceptResult MemPoolAccept::AcceptMultipleTransactions(const std:: } } + // Run package-based dust spentness checks + if (m_pool.m_opts.require_standard) { + if (const auto ephemeral_violation{CheckEphemeralSpends(txns, m_pool.m_opts.dust_relay_feerate)}) { + const Txid parent_txid = ephemeral_violation.value(); + TxValidationState child_state; + child_state.Invalid(TxValidationResult::TX_MEMPOOL_POLICY, "missing-ephemeral-spends", + strprintf("tx %s has unspent ephemeral dust", parent_txid.ToString())); + package_state.Invalid(PackageValidationResult::PCKG_TX, "unspent-dust"); + results.emplace(parent_txid, MempoolAcceptResult::Failure(child_state)); + return PackageMempoolAcceptResult(package_state, std::move(results)); + } + } + // Transactions must meet two minimum feerates: the mempool minimum fee and min relay fee. // For transactions consisting of exactly one child and its parents, it suffices to use the // package feerate (total modified fees / total virtual size) to check this requirement. diff --git a/test/functional/data/invalid_txs.py b/test/functional/data/invalid_txs.py index 33054fd5173e15..3dd1d3613be098 100644 --- a/test/functional/data/invalid_txs.py +++ b/test/functional/data/invalid_txs.py @@ -252,7 +252,7 @@ def get_tx(self): vin = self.valid_txin vin.scriptSig = CScript([opcode]) tx.vin.append(vin) - tx.vout.append(CTxOut(1, basic_p2sh)) + tx.vout.append(CTxOut(1000, basic_p2sh)) tx.calc_sha256() return tx