diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 488bd3fc74b..22c850e7755 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -252,6 +252,7 @@ add_library(bitcoin_node STATIC EXCLUDE_FROM_ALL node/utxo_snapshot.cpp node/warnings.cpp noui.cpp + policy/ephemeral_policy.cpp policy/fees.cpp policy/fees_args.cpp policy/packages.cpp diff --git a/src/kernel/CMakeLists.txt b/src/kernel/CMakeLists.txt index 6ea16e8a425..2e07ba042a4 100644 --- a/src/kernel/CMakeLists.txt +++ b/src/kernel/CMakeLists.txt @@ -33,6 +33,7 @@ add_library(bitcoinkernel ../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 00000000000..6854822e351 --- /dev/null +++ b/src/policy/ephemeral_policy.cpp @@ -0,0 +1,78 @@ +// 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 HasDust(const CTransactionRef& tx, CFeeRate dust_relay_rate) +{ + return std::any_of(tx->vout.cbegin(), tx->vout.cend(), [&](const auto& output) { return IsDust(output, dust_relay_rate); }); +} + +bool CheckValidEphemeralTx(const CTransactionRef& tx, CFeeRate dust_relay_rate, CAmount base_fee, CAmount mod_fee, TxValidationState& state) +{ + // We never want to give incentives to mine this transaction alone + if ((base_fee != 0 || mod_fee != 0) && HasDust(tx, dust_relay_rate)) { + return state.Invalid(TxValidationResult::TX_NOT_STANDARD, "dust", "tx with dust output must be 0-fee"); + } + + return true; +} + +std::optional CheckEphemeralSpends(const Package& package, CFeeRate dust_relay_rate, const CTxMemPool& tx_pool) +{ + if (!Assume(std::all_of(package.cbegin(), package.cend(), [](const auto& tx){return tx != nullptr;}))) { + // Bail out of spend checks if caller gave us an invalid package + return std::nullopt; + } + + std::map map_txid_ref; + for (const auto& tx : package) { + map_txid_ref[tx->GetHash()] = tx; + } + + for (const auto& tx : package) { + Txid txid = tx->GetHash(); + std::unordered_set processed_parent_set; + std::unordered_set unspent_parent_dust; + + for (const auto& tx_input : tx->vin) { + const Txid& parent_txid{tx_input.prevout.hash}; + // Skip parents we've already checked dust for + if (processed_parent_set.contains(parent_txid)) continue; + + // We look for an in-package or in-mempool dependency + CTransactionRef parent_ref = nullptr; + if (auto it = map_txid_ref.find(parent_txid); it != map_txid_ref.end()) { + parent_ref = it->second; + } else { + parent_ref = tx_pool.get(parent_txid); + } + + // Check for dust on parents + if (parent_ref) { + for (uint32_t out_index = 0; out_index < parent_ref->vout.size(); out_index++) { + const auto& tx_output = parent_ref->vout[out_index]; + if (IsDust(tx_output, dust_relay_rate)) { + unspent_parent_dust.insert(COutPoint(parent_txid, out_index)); + } + } + } + + processed_parent_set.insert(parent_txid); + } + + // Now that we have gathered parents' dust, make sure it's spent + // by the child + for (const auto& tx_input : tx->vin) { + unspent_parent_dust.erase(tx_input.prevout); + } + + if (!unspent_parent_dust.empty()) { + return txid; + } + } + + return std::nullopt; +} diff --git a/src/policy/ephemeral_policy.h b/src/policy/ephemeral_policy.h new file mode 100644 index 00000000000..26140f9a020 --- /dev/null +++ b/src/policy/ephemeral_policy.h @@ -0,0 +1,55 @@ +// 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 unduly 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, or the child's descendants, + * are the only way to bring fees. + */ + +/** Returns true if transaction contains dust */ +bool HasDust(const CTransactionRef& tx, CFeeRate dust_relay_rate); + +/* All the following checks are only called if standardness rules are being applied. */ + +/** Must be called for each transaction once transaction fees are known. + * Does context-less checks about a single transaction. + * Returns false if the fee is non-zero and dust exists, populating state. True otherwise. + */ +bool CheckValidEphemeralTx(const CTransactionRef& tx, CFeeRate dust_relay_rate, CAmount base_fee, CAmount mod_fee, TxValidationState& state); + +/** Must be called for each transaction(package) if any dust is in the package. + * Checks that each transaction's parents have their dust spent by the child, + * where parents are either in the mempool or in the package itself. + * The function returns std::nullopt if all dust is properly spent, or the txid of the violating child spend. + */ +std::optional CheckEphemeralSpends(const Package& package, CFeeRate dust_relay_rate, const CTxMemPool& tx_pool); + +#endif // BITCOIN_POLICY_EPHEMERAL_POLICY_H diff --git a/src/policy/policy.cpp b/src/policy/policy.cpp index 68d879b5b81..21c35af5ccb 100644 --- a/src/policy/policy.cpp +++ b/src/policy/policy.cpp @@ -129,6 +129,7 @@ bool IsStandardTx(const CTransaction& tx, const std::optional& max_dat } unsigned int nDataOut = 0; + unsigned int num_dust_outputs{0}; TxoutType whichType; for (const CTxOut& txout : tx.vout) { if (!::IsStandard(txout.scriptPubKey, max_datacarrier_bytes, whichType)) { @@ -142,11 +143,16 @@ bool IsStandardTx(const CTransaction& tx, const std::optional& max_dat reason = "bare-multisig"; return false; } else if (IsDust(txout, dust_relay_fee)) { - reason = "dust"; - return false; + num_dust_outputs++; } } + // Only MAX_DUST_OUTPUTS_PER_TX dust is permitted(on otherwise valid ephemeral dust) + if (num_dust_outputs > MAX_DUST_OUTPUTS_PER_TX) { + reason = "dust"; + return false; + } + // only one OP_RETURN txout is permitted if (nDataOut > 1) { reason = "multi-op-return"; diff --git a/src/policy/policy.h b/src/policy/policy.h index a82488a28c9..0488f8dbee8 100644 --- a/src/policy/policy.h +++ b/src/policy/policy.h @@ -77,6 +77,10 @@ static const unsigned int MAX_OP_RETURN_RELAY = 83; */ static constexpr unsigned int EXTRA_DESCENDANT_TX_SIZE_LIMIT{10000}; +/** + * Maximum number of ephemeral dust outputs allowed. + */ +static constexpr unsigned int MAX_DUST_OUTPUTS_PER_TX{1}; /** * Mandatory script verification flags that all new transactions must comply with for diff --git a/src/test/transaction_tests.cpp b/src/test/transaction_tests.cpp index 3430a5bbfa0..3e4c085c0ed 100644 --- a/src/test/transaction_tests.cpp +++ b/src/test/transaction_tests.cpp @@ -813,6 +813,11 @@ BOOST_AUTO_TEST_CASE(test_IsStandard) // Check dust with default relay fee: CAmount nDustThreshold = 182 * g_dust.GetFeePerK() / 1000; BOOST_CHECK_EQUAL(nDustThreshold, 546); + + // Add dust output to take dust slot, still standard! + t.vout.emplace_back(0, t.vout[0].scriptPubKey); + CheckIsStandard(t); + // dust: t.vout[0].nValue = nDustThreshold - 1; CheckIsNotStandard(t, "dust"); @@ -969,6 +974,10 @@ BOOST_AUTO_TEST_CASE(test_IsStandard) CheckIsNotStandard(t, "bare-multisig"); g_bare_multi = DEFAULT_PERMIT_BAREMULTISIG; + // Add dust output to take dust slot + assert(t.vout.size() == 1); + t.vout.emplace_back(0, t.vout[0].scriptPubKey); + // Check compressed P2PK outputs dust threshold (must have leading 02 or 03) t.vout[0].scriptPubKey = CScript() << std::vector(33, 0x02) << OP_CHECKSIG; t.vout[0].nValue = 576; diff --git a/src/validation.cpp b/src/validation.cpp index 187f7e9718d..62cae2cfb50 100644 --- a/src/validation.cpp +++ b/src/validation.cpp @@ -32,6 +32,7 @@ #include #include #include +#include #include #include #include @@ -912,6 +913,13 @@ bool MemPoolAccept::PreChecks(ATMPArgs& args, Workspace& ws) fSpendsCoinbase, nSigOpsCost, lock_points.value())); ws.m_vsize = entry->GetTxSize(); + // Enforces 0-fee for dust transactions, no incentive to be mined alone + if (m_pool.m_opts.require_standard) { + if (!CheckValidEphemeralTx(ptx, m_pool.m_opts.dust_relay_feerate, ws.m_base_fees, ws.m_modified_fees, state)) { + return false; // state filled in by CheckValidEphemeralTx + } + } + if (nSigOpsCost > MAX_STANDARD_TX_SIGOPS_COST) return state.Invalid(TxValidationResult::TX_NOT_STANDARD, "bad-txns-too-many-sigops", strprintf("%d", nSigOpsCost)); @@ -1432,6 +1440,16 @@ MempoolAcceptResult MemPoolAccept::AcceptSingleTransaction(const CTransactionRef return MempoolAcceptResult::Failure(ws.m_state); } + if (m_pool.m_opts.require_standard) { + if (const auto ephemeral_violation{CheckEphemeralSpends(/*package=*/{ptx}, m_pool.m_opts.dust_relay_feerate, m_pool)}) { + const Txid& txid = ephemeral_violation.value(); + Assume(txid == ptx->GetHash()); + ws.m_state.Invalid(TxValidationResult::TX_MEMPOOL_POLICY, "missing-ephemeral-spends", + strprintf("tx %s did not spend parent's ephemeral dust", txid.ToString())); + return MempoolAcceptResult::Failure(ws.m_state); + } + } + if (m_subpackage.m_rbf && !ReplacementChecks(ws)) { if (ws.m_state.GetResult() == TxValidationResult::TX_RECONSIDERABLE) { // Failed for incentives-based fee reasons. Provide the effective feerate and which tx was included. @@ -1570,6 +1588,19 @@ PackageMempoolAcceptResult MemPoolAccept::AcceptMultipleTransactions(const std:: return PackageMempoolAcceptResult(package_state, std::move(results)); } + // Now that we've bounded the resulting possible ancestry count, check package for dust spends + if (m_pool.m_opts.require_standard) { + if (const auto ephemeral_violation{CheckEphemeralSpends(txns, m_pool.m_opts.dust_relay_feerate, m_pool)}) { + const Txid& child_txid = ephemeral_violation.value(); + TxValidationState child_state; + child_state.Invalid(TxValidationResult::TX_MEMPOOL_POLICY, "missing-ephemeral-spends", + strprintf("tx %s did not spend parent's ephemeral dust", child_txid.ToString())); + package_state.Invalid(PackageValidationResult::PCKG_TX, "unspent-dust"); + results.emplace(child_txid, MempoolAcceptResult::Failure(child_state)); + return PackageMempoolAcceptResult(package_state, std::move(results)); + } + } + for (Workspace& ws : workspaces) { ws.m_package_feerate = package_feerate; if (!PolicyScriptChecks(args, ws)) {