From e29da81b136df74dbac419940e07c83c9080c631 Mon Sep 17 00:00:00 2001 From: Greg Sanders Date: Fri, 13 Sep 2024 10:27:15 -0400 Subject: [PATCH 1/8] functional test: Add new -dustrelayfee=0 test case This test would catch regressions where ephemeral dust checks are being erroneously applied on outputs that are not actually dust. --- test/functional/mempool_dust.py | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/test/functional/mempool_dust.py b/test/functional/mempool_dust.py index 1ea03a8a9ec91..bc9a12fb603e4 100755 --- a/test/functional/mempool_dust.py +++ b/test/functional/mempool_dust.py @@ -71,9 +71,39 @@ def test_dust_output(self, node: TestNode, dust_relay_fee: Decimal, # finally send the transaction to avoid running out of MiniWallet UTXOs self.wallet.sendrawtransaction(from_node=node, tx_hex=tx_good_hex) + def test_dustrelay(self): + self.log.info("Test that small outputs are acceptable when dust relay rate is set to 0 that would otherwise trigger ephemeral dust rules") + + self.restart_node(0, extra_args=["-dustrelayfee=0"]) + + assert_equal(self.nodes[0].getrawmempool(), []) + + # Double dust, both unspent, with fees. Would have failed individual checks. + # Dust is 1 satoshi create_self_transfer_multi disallows 0 + dusty_tx = self.wallet.create_self_transfer_multi(fee_per_output=1000, amount_per_output=1, num_outputs=2) + dust_txid = self.nodes[0].sendrawtransaction(hexstring=dusty_tx["hex"], maxfeerate=0) + + assert_equal(self.nodes[0].getrawmempool(), [dust_txid]) + + # Spends one dust along with fee input, leave other dust unspent to check ephemeral dust checks aren't being enforced + sweep_tx = self.wallet.create_self_transfer_multi(utxos_to_spend=[self.wallet.get_utxo(), dusty_tx["new_utxos"][0]]) + sweep_txid = self.nodes[0].sendrawtransaction(sweep_tx["hex"]) + + mempool_entries = self.nodes[0].getrawmempool() + assert dust_txid in mempool_entries + assert sweep_txid in mempool_entries + assert_equal(len(mempool_entries), 2) + + # Wipe extra arg to reset dust relay + self.restart_node(0, extra_args=[]) + + assert_equal(self.nodes[0].getrawmempool(), []) + def run_test(self): self.wallet = MiniWallet(self.nodes[0]) + self.test_dustrelay() + # prepare output scripts of each standard type _, uncompressed_pubkey = generate_keypair(compressed=False) _, pubkey = generate_keypair(compressed=True) From c22b317142d3527495f17fa928baea9010580cbb Mon Sep 17 00:00:00 2001 From: Greg Sanders Date: Fri, 19 Jul 2024 12:25:23 -0400 Subject: [PATCH 2/8] 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 - ephemeral dust tx only has one dust output - If the ephemeral dust transaction has a child, the dust is spent by by that child. 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 | 3 ++ src/policy/ephemeral_policy.cpp | 78 +++++++++++++++++++++++++++++++++ src/policy/ephemeral_policy.h | 55 +++++++++++++++++++++++ src/policy/policy.cpp | 10 ++++- src/policy/policy.h | 4 ++ src/test/transaction_tests.cpp | 9 ++++ src/validation.cpp | 31 +++++++++++++ 7 files changed, 188 insertions(+), 2 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 1ccb5332c4408..b38937a48971f 100644 --- a/src/Makefile.am +++ b/src/Makefile.am @@ -243,6 +243,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 \ @@ -951,6 +953,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 0000000000000..6854822e35129 --- /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 0000000000000..26140f9a020b9 --- /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 c72290decfd2e..145228a42ede8 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 4e969f43594ae..ee93555f8e1db 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 bcb9f1fafea61..253e40000b400 100644 --- a/src/test/transaction_tests.cpp +++ b/src/test/transaction_tests.cpp @@ -777,6 +777,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"); @@ -933,6 +938,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 a3c0c741d5e8f..261549219d354 100644 --- a/src/validation.cpp +++ b/src/validation.cpp @@ -32,6 +32,7 @@ #include #include #include +#include #include #include #include @@ -931,6 +932,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)); @@ -1467,6 +1475,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. @@ -1605,6 +1623,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)) { From 298a0a0e7b2aeba3a083fa7dee894fe9c2527b1b Mon Sep 17 00:00:00 2001 From: Greg Sanders Date: Tue, 1 Oct 2024 10:28:28 -0400 Subject: [PATCH 3/8] rpc: disallow in-mempool prioritisation of dusty tx --- src/rpc/mining.cpp | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/src/rpc/mining.cpp b/src/rpc/mining.cpp index ff5918a5ed30b..5e6c68dba938f 100644 --- a/src/rpc/mining.cpp +++ b/src/rpc/mining.cpp @@ -23,6 +23,7 @@ #include #include #include +#include #include #include #include @@ -492,7 +493,15 @@ static RPCHelpMan prioritisetransaction() throw JSONRPCError(RPC_INVALID_PARAMETER, "Priority is no longer supported, dummy argument to prioritisetransaction must be 0."); } - EnsureAnyMemPool(request.context).PrioritiseTransaction(hash, nAmount); + CTxMemPool& mempool = EnsureAnyMemPool(request.context); + + // Non-0 fee dust transactions are not allowed for entry, and modification not allowed afterwards + const auto& tx = mempool.get(hash); + if (tx && HasDust(tx, mempool.m_opts.dust_relay_feerate)) { + throw JSONRPCError(RPC_INVALID_PARAMETER, "Priority is not supported for transactions with dust outputs."); + } + + mempool.PrioritiseTransaction(hash, nAmount); return true; }, }; From 315b8e10e3b69f78d4199f55fee437499ad7e054 Mon Sep 17 00:00:00 2001 From: Greg Sanders Date: Fri, 19 Jul 2024 12:26:09 -0400 Subject: [PATCH 4/8] functional test: Add ephemeral dust tests --- test/functional/mempool_ephemeral_dust.py | 484 ++++++++++++++++++ .../functional/test_framework/mempool_util.py | 14 + test/functional/test_runner.py | 1 + 3 files changed, 499 insertions(+) create mode 100755 test/functional/mempool_ephemeral_dust.py diff --git a/test/functional/mempool_ephemeral_dust.py b/test/functional/mempool_ephemeral_dust.py new file mode 100755 index 0000000000000..3dc9b8f86bc96 --- /dev/null +++ b/test/functional/mempool_ephemeral_dust.py @@ -0,0 +1,484 @@ +#!/usr/bin/env python3 +# 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. + +from decimal import Decimal + +from test_framework.messages import ( + COIN, + CTxOut, +) +from test_framework.test_framework import BitcoinTestFramework +from test_framework.mempool_util import assert_mempool_contents +from test_framework.util import ( + assert_equal, + assert_greater_than, + assert_raises_rpc_error, +) +from test_framework.wallet import ( + MiniWallet, +) + +class EphemeralDustTest(BitcoinTestFramework): + def set_test_params(self): + # Mempools should match via 1P1C p2p relay + self.num_nodes = 2 + self.setup_clean_chain = True + + # Don't test trickling logic + self.noban_tx_relay = True + + def add_output_to_create_multi_result(self, result, output_value=0): + """ Add output without changing absolute tx fee + """ + assert len(result["tx"].vout) > 0 + assert result["tx"].vout[0].nValue >= output_value + result["tx"].vout.append(CTxOut(output_value, result["tx"].vout[0].scriptPubKey)) + # Take value from first output + result["tx"].vout[0].nValue -= output_value + result["new_utxos"][0]["value"] = Decimal(result["tx"].vout[0].nValue) / COIN + new_txid = result["tx"].rehash() + result["txid"] = new_txid + result["wtxid"] = result["tx"].getwtxid() + result["hex"] = result["tx"].serialize().hex() + for new_utxo in result["new_utxos"]: + new_utxo["txid"] = new_txid + new_utxo["wtxid"] = result["tx"].getwtxid() + + result["new_utxos"].append({"txid": new_txid, "vout": len(result["tx"].vout) - 1, "value": Decimal(output_value) / COIN, "height": 0, "coinbase": False, "confirmations": 0}) + + def run_test(self): + + node = self.nodes[0] + self.wallet = MiniWallet(node) + self.generate(self.wallet, 200) + + self.test_normal_dust() + self.test_sponsor_cycle() + self.test_node_restart() + self.test_fee_having_parent() + self.test_multidust() + self.test_nonzero_dust() + self.test_non_truc() + self.test_unspent_ephemeral() + self.test_reorgs() + self.test_free_relay() + + def test_normal_dust(self): + self.log.info("Create 0-value dusty output, show that it works inside truc when spent in package") + + assert_equal(self.nodes[0].getrawmempool(), []) + + dusty_tx = self.wallet.create_self_transfer_multi(fee_per_output=0, version=3) + self.add_output_to_create_multi_result(dusty_tx) + + sweep_tx = self.wallet.create_self_transfer_multi(utxos_to_spend=dusty_tx["new_utxos"], version=3) + + # Test doesn't work because lack of package feerates + test_res = self.nodes[0].testmempoolaccept([dusty_tx["hex"], sweep_tx["hex"]]) + assert not test_res[0]["allowed"] + assert_equal(test_res[0]["reject-reason"], "min relay fee not met") + + # And doesn't work on its own + assert_raises_rpc_error(-26, "min relay fee not met", self.nodes[0].sendrawtransaction, dusty_tx["hex"]) + + # If we add modified fees, it is still not allowed due to dust check + self.nodes[0].prioritisetransaction(txid=dusty_tx["txid"], dummy=0, fee_delta=COIN) + test_res = self.nodes[0].testmempoolaccept([dusty_tx["hex"]]) + assert not test_res[0]["allowed"] + assert_equal(test_res[0]["reject-reason"], "dust") + # Reset priority + self.nodes[0].prioritisetransaction(txid=dusty_tx["txid"], dummy=0, fee_delta=-COIN) + assert_equal(self.nodes[0].getprioritisedtransactions(), {}) + + # Package evaluation succeeds + res = self.nodes[0].submitpackage([dusty_tx["hex"], sweep_tx["hex"]]) + assert_equal(res["package_msg"], "success") + assert_mempool_contents(self, self.nodes[0], expected=[dusty_tx["tx"], sweep_tx["tx"]]) + + # Entry is denied when non-0-fee, either base or unmodified. + # If in-mempool, we're not allowed to prioritise due to detected dust output + assert_raises_rpc_error(-8, "Priority is not supported for transactions with dust outputs.", self.nodes[0].prioritisetransaction, dusty_tx["txid"], 0, 1) + assert_equal(self.nodes[0].getprioritisedtransactions(), {}) + + self.generate(self.nodes[0], 1) + assert_equal(self.nodes[0].getrawmempool(), []) + + def test_node_restart(self): + self.log.info("Test that an ephemeral package is rejected on restart due to individual evaluation") + + assert_equal(self.nodes[0].getrawmempool(), []) + + dusty_tx = self.wallet.create_self_transfer_multi(fee_per_output=0, version=3) + self.add_output_to_create_multi_result(dusty_tx) + + sweep_tx = self.wallet.create_self_transfer_multi(utxos_to_spend=dusty_tx["new_utxos"], version=3) + + res = self.nodes[0].submitpackage([dusty_tx["hex"], sweep_tx["hex"]]) + assert_equal(res["package_msg"], "success") + assert_equal(len(self.nodes[0].getrawmempool()), 2) + assert_mempool_contents(self, self.nodes[0], expected=[dusty_tx["tx"], sweep_tx["tx"]]) + + # Node restart; doesn't allow allow ephemeral transaction back in due to individual submission + # resulting in 0-fee. Supporting re-submission of CPFP packages on restart is desired but not + # yet implemented. + self.restart_node(0) + self.restart_node(1) + self.connect_nodes(0, 1) + assert_mempool_contents(self, self.nodes[0], expected=[]) + + def test_fee_having_parent(self): + self.log.info("Test that a transaction with ephemeral dust may not have non-0 base fee") + + assert_equal(self.nodes[0].getrawmempool(), []) + + sats_fee = 1 + dusty_tx = self.wallet.create_self_transfer_multi(fee_per_output=sats_fee, version=3) + self.add_output_to_create_multi_result(dusty_tx) + assert_equal(int(COIN * dusty_tx["fee"]), sats_fee) # has fees + assert_greater_than(dusty_tx["tx"].vout[0].nValue, 330) # main output is not dust + assert_equal(dusty_tx["tx"].vout[1].nValue, 0) # added one is dust + + sweep_tx = self.wallet.create_self_transfer_multi(utxos_to_spend=dusty_tx["new_utxos"], version=3) + + # When base fee is non-0, we report dust like usual + res = self.nodes[0].submitpackage([dusty_tx["hex"], sweep_tx["hex"]]) + assert_equal(res["package_msg"], "transaction failed") + assert_equal(res["tx-results"][dusty_tx["wtxid"]]["error"], "dust, tx with dust output must be 0-fee") + + # Priority is ignored: rejected even if modified fee is 0 + self.nodes[0].prioritisetransaction(txid=dusty_tx["txid"], dummy=0, fee_delta=-sats_fee) + self.nodes[1].prioritisetransaction(txid=dusty_tx["txid"], dummy=0, fee_delta=-sats_fee) + res = self.nodes[0].submitpackage([dusty_tx["hex"], sweep_tx["hex"]]) + assert_equal(res["package_msg"], "transaction failed") + assert_equal(res["tx-results"][dusty_tx["wtxid"]]["error"], "dust, tx with dust output must be 0-fee") + + # Will not be accepted if base fee is 0 with modified fee of non-0 + dusty_tx = self.wallet.create_self_transfer_multi(fee_per_output=0, version=3) + self.add_output_to_create_multi_result(dusty_tx) + + sweep_tx = self.wallet.create_self_transfer_multi(utxos_to_spend=dusty_tx["new_utxos"], version=3) + + self.nodes[0].prioritisetransaction(txid=dusty_tx["txid"], dummy=0, fee_delta=1000) + self.nodes[1].prioritisetransaction(txid=dusty_tx["txid"], dummy=0, fee_delta=1000) + + # It's rejected submitted alone + test_res = self.nodes[0].testmempoolaccept([dusty_tx["hex"]]) + assert not test_res[0]["allowed"] + assert_equal(test_res[0]["reject-reason"], "dust") + + # Or as a package + res = self.nodes[0].submitpackage([dusty_tx["hex"], sweep_tx["hex"]]) + assert_equal(res["package_msg"], "transaction failed") + assert_equal(res["tx-results"][dusty_tx["wtxid"]]["error"], "dust, tx with dust output must be 0-fee") + + assert_mempool_contents(self, self.nodes[0], expected=[]) + + def test_multidust(self): + self.log.info("Test that a transaction with multiple ephemeral dusts is not allowed") + + assert_mempool_contents(self, self.nodes[0], expected=[]) + + dusty_tx = self.wallet.create_self_transfer_multi(fee_per_output=0, version=3) + self.add_output_to_create_multi_result(dusty_tx) + self.add_output_to_create_multi_result(dusty_tx) + + sweep_tx = self.wallet.create_self_transfer_multi(utxos_to_spend=dusty_tx["new_utxos"], version=3) + + res = self.nodes[0].submitpackage([dusty_tx["hex"], sweep_tx["hex"]]) + assert_equal(res["package_msg"], "transaction failed") + assert_equal(res["tx-results"][dusty_tx["wtxid"]]["error"], "dust") + assert_equal(self.nodes[0].getrawmempool(), []) + + def test_nonzero_dust(self): + self.log.info("Test that a single output of any satoshi amount is allowed, not checking spending") + + # We aren't checking spending, allow it in with no fee + self.restart_node(0, extra_args=["-minrelaytxfee=0"]) + self.restart_node(1, extra_args=["-minrelaytxfee=0"]) + self.connect_nodes(0, 1) + + # 330 is dust threshold for taproot outputs + for value in [1, 329, 330]: + assert_equal(self.nodes[0].getrawmempool(), []) + + dusty_tx = self.wallet.create_self_transfer_multi(fee_per_output=0, version=3) + self.add_output_to_create_multi_result(dusty_tx, value) + + test_res = self.nodes[0].testmempoolaccept([dusty_tx["hex"]]) + assert test_res[0]["allowed"] + + self.restart_node(0, extra_args=[]) + self.restart_node(1, extra_args=[]) + self.connect_nodes(0, 1) + assert_mempool_contents(self, self.nodes[0], expected=[]) + + # N.B. If individual minrelay requirement is dropped, this test can be dropped + def test_non_truc(self): + self.log.info("Test that v2 dust-having transaction is rejected even if spent, because of min relay requirement") + + assert_equal(self.nodes[0].getrawmempool(), []) + + dusty_tx = self.wallet.create_self_transfer_multi(fee_per_output=0, version=2) + self.add_output_to_create_multi_result(dusty_tx) + + sweep_tx = self.wallet.create_self_transfer_multi(utxos_to_spend=dusty_tx["new_utxos"], version=2) + + res = self.nodes[0].submitpackage([dusty_tx["hex"], sweep_tx["hex"]]) + assert_equal(res["package_msg"], "transaction failed") + assert_equal(res["tx-results"][dusty_tx["wtxid"]]["error"], "min relay fee not met, 0 < 147") + + assert_equal(self.nodes[0].getrawmempool(), []) + + def test_unspent_ephemeral(self): + self.log.info("Test that spending from a tx with ephemeral outputs is only allowed if dust is spent as well") + + assert_equal(self.nodes[0].getrawmempool(), []) + + dusty_tx = self.wallet.create_self_transfer_multi(fee_per_output=0, version=3) + self.add_output_to_create_multi_result(dusty_tx, 329) + + # Valid sweep we will RBF incorrectly by not spending dust as well + sweep_tx = self.wallet.create_self_transfer_multi(utxos_to_spend=dusty_tx["new_utxos"], version=3) + self.nodes[0].submitpackage([dusty_tx["hex"], sweep_tx["hex"]]) + assert_mempool_contents(self, self.nodes[0], expected=[dusty_tx["tx"], sweep_tx["tx"]]) + + # Doesn't spend in-mempool dust output from parent + unspent_sweep_tx = self.wallet.create_self_transfer_multi(fee_per_output=2000, utxos_to_spend=[dusty_tx["new_utxos"][0]], version=3) + assert_greater_than(unspent_sweep_tx["fee"], sweep_tx["fee"]) + res = self.nodes[0].submitpackage([dusty_tx["hex"], unspent_sweep_tx["hex"]]) + assert_equal(res["tx-results"][unspent_sweep_tx["wtxid"]]["error"], f"missing-ephemeral-spends, tx {unspent_sweep_tx['txid']} did not spend parent's ephemeral dust") + assert_raises_rpc_error(-26, f"missing-ephemeral-spends, tx {unspent_sweep_tx['txid']} did not spend parent's ephemeral dust", self.nodes[0].sendrawtransaction, unspent_sweep_tx["hex"]) + assert_mempool_contents(self, self.nodes[0], expected=[dusty_tx["tx"], sweep_tx["tx"]]) + + # Spend works with dust spent + sweep_tx_2 = self.wallet.create_self_transfer_multi(fee_per_output=2000, utxos_to_spend=dusty_tx["new_utxos"], version=3) + assert sweep_tx["hex"] != sweep_tx_2["hex"] + res = self.nodes[0].submitpackage([dusty_tx["hex"], sweep_tx_2["hex"]]) + assert_equal(res["package_msg"], "success") + + # Re-set and test again with nothing from package in mempool this time + self.generate(self.nodes[0], 1) + assert_equal(self.nodes[0].getrawmempool(), []) + + dusty_tx = self.wallet.create_self_transfer_multi(fee_per_output=0, version=3) + self.add_output_to_create_multi_result(dusty_tx, 329) + + # Spend non-dust only + unspent_sweep_tx = self.wallet.create_self_transfer_multi(utxos_to_spend=[dusty_tx["new_utxos"][0]], version=3) + + res = self.nodes[0].submitpackage([dusty_tx["hex"], unspent_sweep_tx["hex"]]) + assert_equal(res["package_msg"], "unspent-dust") + + assert_equal(self.nodes[0].getrawmempool(), []) + + # Now spend dust only which should work + second_coin = self.wallet.get_utxo() # another fee-bringing coin + sweep_tx = self.wallet.create_self_transfer_multi(utxos_to_spend=[dusty_tx["new_utxos"][1], second_coin], version=3) + + res = self.nodes[0].submitpackage([dusty_tx["hex"], sweep_tx["hex"]]) + assert_equal(res["package_msg"], "success") + assert_mempool_contents(self, self.nodes[0], expected=[dusty_tx["tx"], sweep_tx["tx"]]) + + self.generate(self.nodes[0], 1) + assert_mempool_contents(self, self.nodes[0], expected=[]) + + def test_sponsor_cycle(self): + self.log.info("Test that dust txn is not evicted when it becomes childless, but won't be mined") + + assert_equal(self.nodes[0].getrawmempool(), []) + + dusty_tx = self.wallet.create_self_transfer_multi( + fee_per_output=0, + version=3 + ) + + self.add_output_to_create_multi_result(dusty_tx) + + sponsor_coin = self.wallet.get_utxo() + + # Bring "fee" input that can be double-spend separately + sweep_tx = self.wallet.create_self_transfer_multi(utxos_to_spend=dusty_tx["new_utxos"] + [sponsor_coin], version=3) + + res = self.nodes[0].submitpackage([dusty_tx["hex"], sweep_tx["hex"]]) + assert_equal(res["package_msg"], "success") + assert_equal(len(self.nodes[0].getrawmempool()), 2) + # sync to make sure unsponsor_tx hits second node's mempool after initial package + assert_mempool_contents(self, self.nodes[0], expected=[dusty_tx["tx"], sweep_tx["tx"]]) + + # Now we RBF away the child using the sponsor input only + unsponsor_tx = self.wallet.create_self_transfer_multi( + utxos_to_spend=[sponsor_coin], + num_outputs=1, + fee_per_output=2000, + version=3 + ) + self.nodes[0].sendrawtransaction(unsponsor_tx["hex"]) + + # Parent is now childless and fee-free, so will not be mined + entry_info = self.nodes[0].getmempoolentry(dusty_tx["txid"]) + assert_equal(entry_info["descendantcount"], 1) + assert_equal(entry_info["fees"]["descendant"], Decimal(0)) + + assert_mempool_contents(self, self.nodes[0], expected=[dusty_tx["tx"], unsponsor_tx["tx"]]) + + # Dust tx is not mined + self.generate(self.nodes[0], 1) + assert_mempool_contents(self, self.nodes[0], expected=[dusty_tx["tx"]]) + + # Create sweep that doesn't spend conflicting sponsor coin + sweep_tx = self.wallet.create_self_transfer_multi(utxos_to_spend=dusty_tx["new_utxos"], version=3) + + # Can resweep + self.nodes[0].sendrawtransaction(sweep_tx["hex"]) + assert_mempool_contents(self, self.nodes[0], expected=[dusty_tx["tx"], sweep_tx["tx"]]) + + self.generate(self.nodes[0], 1) + assert_mempool_contents(self, self.nodes[0], expected=[]) + + def test_reorgs(self): + self.log.info("Test that reorgs breaking the truc topology doesn't cause issues") + + assert_equal(self.nodes[0].getrawmempool(), []) + + # Many shallow re-orgs confuse block gossiping making test less reliable otherwise + self.disconnect_nodes(0, 1) + + # Get dusty tx mined, then check that it makes it back into mempool on reorg + # due to bypass_limits allowing 0-fee individually + dusty_tx = self.wallet.create_self_transfer_multi(fee_per_output=0, version=3) + self.add_output_to_create_multi_result(dusty_tx) + assert_raises_rpc_error(-26, "min relay fee not met", self.nodes[0].sendrawtransaction, dusty_tx["hex"]) + + block_res = self.nodes[0].rpc.generateblock(self.wallet.get_address(), [dusty_tx["hex"]]) + self.nodes[0].invalidateblock(block_res["hash"]) + assert_mempool_contents(self, self.nodes[0], expected=[dusty_tx["tx"]], sync=False) + + # Create a sweep that has dust of its own and leaves dusty_tx's dust unspent + sweep_tx = self.wallet.create_self_transfer_multi(fee_per_output=0, utxos_to_spend=[dusty_tx["new_utxos"][0]], version=3) + self.add_output_to_create_multi_result(sweep_tx) + assert_raises_rpc_error(-26, "min relay fee not met", self.nodes[0].sendrawtransaction, sweep_tx["hex"]) + + # Mine the sweep then re-org, the sweep will not make it back in due to spend checks + block_res = self.nodes[0].rpc.generateblock(self.wallet.get_address(), [dusty_tx["hex"], sweep_tx["hex"]]) + self.nodes[0].invalidateblock(block_res["hash"]) + assert_mempool_contents(self, self.nodes[0], expected=[dusty_tx["tx"]], sync=False) + + # Also should happen if dust is swept + sweep_tx_2 = self.wallet.create_self_transfer_multi(fee_per_output=0, utxos_to_spend=dusty_tx["new_utxos"], version=3) + self.add_output_to_create_multi_result(sweep_tx_2) + assert_raises_rpc_error(-26, "min relay fee not met", self.nodes[0].sendrawtransaction, sweep_tx_2["hex"]) + + reconsider_block_res = self.nodes[0].rpc.generateblock(self.wallet.get_address(), [dusty_tx["hex"], sweep_tx_2["hex"]]) + self.nodes[0].invalidateblock(reconsider_block_res["hash"]) + assert_mempool_contents(self, self.nodes[0], expected=[dusty_tx["tx"], sweep_tx_2["tx"]], sync=False) + + # TRUC transactions restriction for ephemeral dust disallows further spends of ancestor chains + child_tx = self.wallet.create_self_transfer_multi(utxos_to_spend=sweep_tx_2["new_utxos"], version=3) + assert_raises_rpc_error(-26, "TRUC-violation", self.nodes[0].sendrawtransaction, child_tx["hex"]) + + self.nodes[0].reconsiderblock(reconsider_block_res["hash"]) + assert_equal(self.nodes[0].getrawmempool(), []) + + self.log.info("Test that ephemeral dust tx with fees or multi dust don't enter mempool via reorg") + multi_dusty_tx = self.wallet.create_self_transfer_multi(fee_per_output=0, version=3) + self.add_output_to_create_multi_result(multi_dusty_tx) + self.add_output_to_create_multi_result(multi_dusty_tx) + + block_res = self.nodes[0].rpc.generateblock(self.wallet.get_address(), [multi_dusty_tx["hex"]]) + self.nodes[0].invalidateblock(block_res["hash"]) + assert_equal(self.nodes[0].getrawmempool(), []) + + # With fee and one dust + dusty_fee_tx = self.wallet.create_self_transfer_multi(fee_per_output=1, version=3) + self.add_output_to_create_multi_result(dusty_fee_tx) + + block_res = self.nodes[0].rpc.generateblock(self.wallet.get_address(), [dusty_fee_tx["hex"]]) + self.nodes[0].invalidateblock(block_res["hash"]) + assert_equal(self.nodes[0].getrawmempool(), []) + + # Re-connect and make sure we have same state still + self.connect_nodes(0, 1) + self.sync_all() + + # N.B. this extra_args can be removed post cluster mempool + def test_free_relay(self): + self.log.info("Test that ephemeral dust works in non-TRUC contexts when there's no minrelay requirement") + + # Note: since minrelay is 0, it is not testing 1P1C relay + self.restart_node(0, extra_args=["-minrelaytxfee=0"]) + self.restart_node(1, extra_args=["-minrelaytxfee=0"]) + self.connect_nodes(0, 1) + + assert_equal(self.nodes[0].getrawmempool(), []) + + dusty_tx = self.wallet.create_self_transfer_multi(fee_per_output=0, version=2) + self.add_output_to_create_multi_result(dusty_tx) + + sweep_tx = self.wallet.create_self_transfer_multi(utxos_to_spend=dusty_tx["new_utxos"], version=2) + + self.nodes[0].submitpackage([dusty_tx["hex"], sweep_tx["hex"]]) + + assert_mempool_contents(self, self.nodes[0], expected=[dusty_tx["tx"], sweep_tx["tx"]]) + + # generate coins for next tests + self.generate(self.nodes[0], 1) + self.wallet.rescan_utxos() + assert_equal(self.nodes[0].getrawmempool(), []) + + self.log.info("Test batched ephemeral dust sweep") + dusty_txs = [] + for _ in range(24): + dusty_txs.append(self.wallet.create_self_transfer_multi(fee_per_output=0, version=2)) + self.add_output_to_create_multi_result(dusty_txs[-1]) + + all_parent_utxos = [utxo for tx in dusty_txs for utxo in tx["new_utxos"]] + + # Missing one dust spend from a single parent, child rejected + insufficient_sweep_tx = self.wallet.create_self_transfer_multi(fee_per_output=25000, utxos_to_spend=all_parent_utxos[:-1], version=2) + + res = self.nodes[0].submitpackage([dusty_tx["hex"] for dusty_tx in dusty_txs] + [insufficient_sweep_tx["hex"]]) + assert_equal(res['package_msg'], "transaction failed") + assert_equal(res['tx-results'][insufficient_sweep_tx['wtxid']]['error'], f"missing-ephemeral-spends, tx {insufficient_sweep_tx['txid']} did not spend parent's ephemeral dust") + # Everything got in except for insufficient spend + assert_mempool_contents(self, self.nodes[0], expected=[dusty_tx["tx"] for dusty_tx in dusty_txs]) + + # Next put some parents in mempool, but not others, and test unspent dust again with all parents spent + B_coin = self.wallet.get_utxo() # coin to cycle out CPFP + sweep_all_but_one_tx = self.wallet.create_self_transfer_multi(fee_per_output=20000, utxos_to_spend=all_parent_utxos[:-2] + [B_coin], version=2) + res = self.nodes[0].submitpackage([dusty_tx["hex"] for dusty_tx in dusty_txs[:-1]] + [sweep_all_but_one_tx["hex"]]) + assert_equal(res['package_msg'], "success") + assert_mempool_contents(self, self.nodes[0], expected=[dusty_tx["tx"] for dusty_tx in dusty_txs] + [sweep_all_but_one_tx["tx"]]) + + res = self.nodes[0].submitpackage([dusty_tx["hex"] for dusty_tx in dusty_txs] + [insufficient_sweep_tx["hex"]]) + assert_equal(res['package_msg'], "transaction failed") + assert_equal(res['tx-results'][insufficient_sweep_tx["wtxid"]]["error"], f"missing-ephemeral-spends, tx {insufficient_sweep_tx['txid']} did not spend parent's ephemeral dust") + assert_mempool_contents(self, self.nodes[0], expected=[dusty_tx["tx"] for dusty_tx in dusty_txs] + [sweep_all_but_one_tx["tx"]]) + + # Cycle out the partial sweep to avoid triggering package RBF behavior which limits package to no in-mempool ancestors + cancel_sweep = self.wallet.create_self_transfer_multi(fee_per_output=21000, utxos_to_spend=[B_coin], version=2) + self.nodes[0].sendrawtransaction(cancel_sweep["hex"]) + assert_mempool_contents(self, self.nodes[0], expected=[dusty_tx["tx"] for dusty_tx in dusty_txs] + [cancel_sweep["tx"]]) + + # Sweeps all dust, where all dusty txs are already in-mempool + sweep_tx = self.wallet.create_self_transfer_multi(fee_per_output=25000, utxos_to_spend=all_parent_utxos, version=2) + + res = self.nodes[0].submitpackage([dusty_tx["hex"] for dusty_tx in dusty_txs] + [sweep_tx["hex"]]) + assert_equal(res['package_msg'], "success") + assert_mempool_contents(self, self.nodes[0], expected=[dusty_tx["tx"] for dusty_tx in dusty_txs] + [sweep_tx["tx"], cancel_sweep["tx"]]) + + self.generate(self.nodes[0], 25) + self.wallet.rescan_utxos() + assert_equal(self.nodes[0].getrawmempool(), []) + + # Other topology tests require relaxation of submitpackage topology + + self.restart_node(0, extra_args=[]) + self.restart_node(1, extra_args=[]) + self.connect_nodes(0, 1) + + assert_equal(self.nodes[0].getrawmempool(), []) + +if __name__ == "__main__": + EphemeralDustTest(__file__).main() diff --git a/test/functional/test_framework/mempool_util.py b/test/functional/test_framework/mempool_util.py index 148cc935ed5b6..d8e33e0642091 100644 --- a/test/functional/test_framework/mempool_util.py +++ b/test/functional/test_framework/mempool_util.py @@ -18,6 +18,20 @@ MiniWallet, ) +def assert_mempool_contents(test_framework, node, expected=None, sync=True): + """Assert that all transactions in expected are in the mempool, + and no additional ones exist. 'expected' is an array of + CTransaction objects + """ + if sync: + test_framework.sync_mempools() + if not expected: + expected = [] + mempool = node.getrawmempool(verbose=False) + assert_equal(len(mempool), len(expected)) + for tx in expected: + assert tx.rehash() in mempool + def fill_mempool(test_framework, node): """Fill mempool until eviction. diff --git a/test/functional/test_runner.py b/test/functional/test_runner.py index 96573dab57e4a..f46079339cd65 100755 --- a/test/functional/test_runner.py +++ b/test/functional/test_runner.py @@ -398,6 +398,7 @@ 'rpc_getdescriptorinfo.py', 'rpc_mempool_info.py', 'rpc_help.py', + 'mempool_ephemeral_dust.py', 'p2p_handshake.py', 'p2p_handshake.py --v2transport', 'feature_dirsymlinks.py', From a72914e3675b074c1ab44d7787df316c01ec5c57 Mon Sep 17 00:00:00 2001 From: Greg Sanders Date: Wed, 24 Jul 2024 12:51:24 -0400 Subject: [PATCH 5/8] test: Add CheckMempoolEphemeralInvariants Checks that transactions in mempool with dust follow expected invariants. --- src/test/util/txmempool.cpp | 48 +++++++++++++++++++++++++++++++++++++ src/test/util/txmempool.h | 12 ++++++++++ 2 files changed, 60 insertions(+) diff --git a/src/test/util/txmempool.cpp b/src/test/util/txmempool.cpp index 9d6b4810d0e96..8b7d2708fe67d 100644 --- a/src/test/util/txmempool.cpp +++ b/src/test/util/txmempool.cpp @@ -141,6 +141,54 @@ std::optional CheckPackageMempoolAcceptResult(const Package& txns, return std::nullopt; } +std::vector GetDustIndexes(const CTransactionRef tx_ref, CFeeRate dust_relay_rate) +{ + std::vector dust_indexes; + for (size_t i = 0; i < tx_ref->vout.size(); ++i) { + const auto& output = tx_ref->vout[i]; + if (IsDust(output, dust_relay_rate)) dust_indexes.push_back(i); + } + + return dust_indexes; +} + +void CheckMempoolEphemeralInvariants(const CTxMemPool& tx_pool) +{ + LOCK(tx_pool.cs); + for (const auto& tx_info : tx_pool.infoAll()) { + const auto& entry = *Assert(tx_pool.GetEntry(tx_info.tx->GetHash())); + + std::vector dust_indexes = GetDustIndexes(tx_info.tx, tx_pool.m_opts.dust_relay_feerate); + + Assert(dust_indexes.size() < 2); + + if (dust_indexes.empty()) continue; + + // Transaction must have no base fee + Assert(entry.GetFee() == 0 && entry.GetModifiedFee() == 0); + + // Transaction has single dust; make sure it's swept or will not be mined + const auto& children = entry.GetMemPoolChildrenConst(); + + // Multiple children should never happen as non-dust-spending child + // can get mined as package + Assert(children.size() < 2); + + if (children.empty()) { + // No children and no fees; modified fees aside won't get mined so it's fine + // Happens naturally if child spend is RBF cycled away. + continue; + } + + // Only-child should be spending the dust + const auto& only_child = children.begin()->get().GetTx(); + COutPoint dust_outpoint{tx_info.tx->GetHash(), dust_indexes[0]}; + Assert(std::any_of(only_child.vin.begin(), only_child.vin.end(), [&dust_outpoint](const CTxIn& txin) { + return txin.prevout == dust_outpoint; + })); + } +} + void CheckMempoolTRUCInvariants(const CTxMemPool& tx_pool) { LOCK(tx_pool.cs); diff --git a/src/test/util/txmempool.h b/src/test/util/txmempool.h index 6d41fdf87ff69..6f657a53eace9 100644 --- a/src/test/util/txmempool.h +++ b/src/test/util/txmempool.h @@ -47,6 +47,18 @@ std::optional CheckPackageMempoolAcceptResult(const Package& txns, bool expect_valid, const CTxMemPool* mempool); +/** Check that we never get into a state where an ephemeral dust + * transaction would be mined without the spend of the dust + * also being mined. This assumes standardness checks are being + * enforced. +*/ +void CheckMempoolEphemeralInvariants(const CTxMemPool& tx_pool); + +/** Return indexes of the transaction's outputs that are considered dust + * at given dust_relay_rate. +*/ +std::vector GetDustIndexes(const CTransactionRef tx_ref, CFeeRate dust_relay_rate); + /** For every transaction in tx_pool, check TRUC invariants: * - a TRUC tx's ancestor count must be within TRUC_ANCESTOR_LIMIT * - a TRUC tx's descendant count must be within TRUC_DESCENDANT_LIMIT From 37961f22c2ccf3ab2f80531cebeff10f8d8d884c Mon Sep 17 00:00:00 2001 From: Greg Sanders Date: Wed, 24 Jul 2024 12:52:15 -0400 Subject: [PATCH 6/8] fuzz: add ephemeral_package_eval harness Works a bit harder to get ephemeral dust transactions into the mempool. --- src/test/fuzz/package_eval.cpp | 218 +++++++++++++++++++++++++++++++++ 1 file changed, 218 insertions(+) diff --git a/src/test/fuzz/package_eval.cpp b/src/test/fuzz/package_eval.cpp index 652c7a7609f4b..f944170474577 100644 --- a/src/test/fuzz/package_eval.cpp +++ b/src/test/fuzz/package_eval.cpp @@ -137,6 +137,219 @@ std::unique_ptr MakeMempool(FuzzedDataProvider& fuzzed_data_provider return mempool; } +std::unique_ptr MakeEphemeralMempool(const NodeContext& node) +{ + // Take the default options for tests... + CTxMemPool::Options mempool_opts{MemPoolOptionsForTest(node)}; + + mempool_opts.check_ratio = 1; + + // Require standardness rules otherwise ephemeral dust is no-op + mempool_opts.require_standard = true; + + // And set minrelay to 0 to allow ephemeral parent tx even with non-TRUC + mempool_opts.min_relay_feerate = CFeeRate(0); + + bilingual_str error; + // ...and construct a CTxMemPool from it + auto mempool{std::make_unique(std::move(mempool_opts), error)}; + Assert(error.empty()); + return mempool; +} + +// Scan mempool for a tx that has spent dust and return a +// prevout of the child that isn't the dusty parent itself. +// This is used to double-spend the child out of the mempool, +// leaving the parent childless. +// This assumes CheckMempoolEphemeralInvariants has passed for tx_pool. +std::optional GetChildEvictingPrevout(const CTxMemPool& tx_pool) +{ + LOCK(tx_pool.cs); + for (const auto& tx_info : tx_pool.infoAll()) { + const auto& entry = *Assert(tx_pool.GetEntry(tx_info.tx->GetHash())); + std::vector dust_indexes{GetDustIndexes(tx_info.tx, tx_pool.m_opts.dust_relay_feerate)}; + if (!dust_indexes.empty()) { + const auto& children = entry.GetMemPoolChildrenConst(); + if (!children.empty()) { + Assert(children.size() == 1); + // Find an input that doesn't spend from parent's txid + const auto& only_child = children.begin()->get().GetTx(); + for (const auto& tx_input : only_child.vin) { + if (tx_input.prevout.hash != tx_info.tx->GetHash()) { + return tx_input.prevout; + } + } + } + } + } + + return std::nullopt; +} + +FUZZ_TARGET(ephemeral_package_eval, .init = initialize_tx_pool) +{ + FuzzedDataProvider fuzzed_data_provider(buffer.data(), buffer.size()); + const auto& node = g_setup->m_node; + auto& chainstate{static_cast(node.chainman->ActiveChainstate())}; + + MockTime(fuzzed_data_provider, chainstate); + + // All RBF-spendable outpoints outside of the unsubmitted package + std::set mempool_outpoints; + std::map outpoints_value; + for (const auto& outpoint : g_outpoints_coinbase_init_mature) { + Assert(mempool_outpoints.insert(outpoint).second); + outpoints_value[outpoint] = 50 * COIN; + } + + auto outpoints_updater = std::make_shared(mempool_outpoints); + node.validation_signals->RegisterSharedValidationInterface(outpoints_updater); + + auto tx_pool_{MakeEphemeralMempool(node)}; + MockedTxPool& tx_pool = *static_cast(tx_pool_.get()); + + chainstate.SetMempool(&tx_pool); + + LIMITED_WHILE(fuzzed_data_provider.ConsumeBool(), 300) + { + Assert(!mempool_outpoints.empty()); + + std::vector txs; + + // Find something we may want to double-spend with two input single tx + std::optional outpoint_to_rbf{GetChildEvictingPrevout(tx_pool)}; + bool should_rbf_eph_spend = outpoint_to_rbf && fuzzed_data_provider.ConsumeBool(); + + // Make small packages + const auto num_txs = should_rbf_eph_spend ? 1 : (size_t) fuzzed_data_provider.ConsumeIntegralInRange(1, 4); + + std::set package_outpoints; + while (txs.size() < num_txs) { + + // Last transaction in a package needs to be a child of parents to get further in validation + // so the last transaction to be generated(in a >1 package) must spend all package-made outputs + // Note that this test currently only spends package outputs in last transaction. + bool last_tx = num_txs > 1 && txs.size() == num_txs - 1; + + // Create transaction to add to the mempool + const CTransactionRef tx = [&] { + CMutableTransaction tx_mut; + tx_mut.version = CTransaction::CURRENT_VERSION; + tx_mut.nLockTime = 0; + // Last tx will sweep half or more of all outpoints from package + const auto num_in = should_rbf_eph_spend ? 2 : + last_tx ? fuzzed_data_provider.ConsumeIntegralInRange(package_outpoints.size()/2 + 1, package_outpoints.size()) : + fuzzed_data_provider.ConsumeIntegralInRange(1, 4); + auto num_out = should_rbf_eph_spend ? 1 : fuzzed_data_provider.ConsumeIntegralInRange(1, 4); + + auto& outpoints = last_tx ? package_outpoints : mempool_outpoints; + + Assert((int)outpoints.size() >= num_in && num_in > 0); + + CAmount amount_in{0}; + for (int i = 0; i < num_in; ++i) { + // Pop random outpoint + auto pop = outpoints.begin(); + std::advance(pop, fuzzed_data_provider.ConsumeIntegralInRange(0, outpoints.size() - 1)); + auto outpoint = *pop; + + if (i == 0 && should_rbf_eph_spend) { + outpoint = *outpoint_to_rbf; + outpoints.erase(outpoint); + } else { + outpoints.erase(pop); + } + // no need to update or erase from outpoints_value + amount_in += outpoints_value.at(outpoint); + + // Create input + CTxIn in; + in.prevout = outpoint; + in.scriptWitness.stack = P2WSH_EMPTY_TRUE_STACK; + + tx_mut.vin.push_back(in); + } + + const auto amount_fee = fuzzed_data_provider.ConsumeIntegralInRange(0, amount_in); + const auto amount_out = (amount_in - amount_fee) / num_out; + for (int i = 0; i < num_out; ++i) { + tx_mut.vout.emplace_back(amount_out, P2WSH_EMPTY); + } + + // Note output amounts can naturally drop to dust on their own. + if (!should_rbf_eph_spend && fuzzed_data_provider.ConsumeBool()) { + uint32_t dust_index = fuzzed_data_provider.ConsumeIntegralInRange(0, num_out); + tx_mut.vout.insert(tx_mut.vout.begin() + dust_index, CTxOut(0, P2WSH_EMPTY)); + } + + auto tx = MakeTransactionRef(tx_mut); + // Restore previously removed outpoints, except in-package outpoints (to allow RBF) + if (!last_tx) { + for (const auto& in : tx->vin) { + Assert(outpoints.insert(in.prevout).second); + } + // Cache the in-package outpoints being made + for (size_t i = 0; i < tx->vout.size(); ++i) { + package_outpoints.emplace(tx->GetHash(), i); + } + } + // We need newly-created values for the duration of this run + for (size_t i = 0; i < tx->vout.size(); ++i) { + outpoints_value[COutPoint(tx->GetHash(), i)] = tx->vout[i].nValue; + } + return tx; + }(); + txs.push_back(tx); + } + + if (fuzzed_data_provider.ConsumeBool()) { + const auto& txid = fuzzed_data_provider.ConsumeBool() ? + txs.back()->GetHash() : + PickValue(fuzzed_data_provider, mempool_outpoints).hash; + const auto delta = fuzzed_data_provider.ConsumeIntegralInRange(-50 * COIN, +50 * COIN); + // We only prioritise out of mempool transactions since PrioritiseTransaction doesn't + // filter for ephemeral dust GetEntry + if (tx_pool.exists(GenTxid::Txid(txid))) { + const auto tx_info{tx_pool.info(GenTxid::Txid(txid))}; + if (GetDustIndexes(tx_info.tx, tx_pool.m_opts.dust_relay_feerate).empty()) { + tx_pool.PrioritiseTransaction(txid.ToUint256(), delta); + } + } + } + + // Remember all added transactions + std::set added; + auto txr = std::make_shared(added); + node.validation_signals->RegisterSharedValidationInterface(txr); + + auto single_submit = txs.size() == 1; + + const auto result_package = WITH_LOCK(::cs_main, + return ProcessNewPackage(chainstate, tx_pool, txs, /*test_accept=*/single_submit, /*client_maxfeerate=*/{})); + + const auto res = WITH_LOCK(::cs_main, return AcceptToMemoryPool(chainstate, txs.back(), GetTime(), + /*bypass_limits=*/fuzzed_data_provider.ConsumeBool(), /*test_accept=*/!single_submit)); + + if (!single_submit && result_package.m_state.GetResult() != PackageValidationResult::PCKG_POLICY) { + // We don't know anything about the validity since transactions were randomly generated, so + // just use result_package.m_state here. This makes the expect_valid check meaningless, but + // we can still verify that the contents of m_tx_results are consistent with m_state. + const bool expect_valid{result_package.m_state.IsValid()}; + Assert(!CheckPackageMempoolAcceptResult(txs, result_package, expect_valid, &tx_pool)); + } + + node.validation_signals->SyncWithValidationInterfaceQueue(); + node.validation_signals->UnregisterSharedValidationInterface(txr); + + CheckMempoolEphemeralInvariants(tx_pool); + } + + node.validation_signals->UnregisterSharedValidationInterface(outpoints_updater); + + WITH_LOCK(::cs_main, tx_pool.check(chainstate.CoinsTip(), chainstate.m_chain.Height() + 1)); +} + + FUZZ_TARGET(tx_package_eval, .init = initialize_tx_pool) { FuzzedDataProvider fuzzed_data_provider(buffer.data(), buffer.size()); @@ -321,6 +534,11 @@ FUZZ_TARGET(tx_package_eval, .init = initialize_tx_pool) } CheckMempoolTRUCInvariants(tx_pool); + + // Dust checks only make sense when dust is enforced + if (tx_pool.m_opts.require_standard) { + CheckMempoolEphemeralInvariants(tx_pool); + } } node.validation_signals->UnregisterSharedValidationInterface(outpoints_updater); From 386c59981792ca255446ea5d43328ca84f60b392 Mon Sep 17 00:00:00 2001 From: Greg Sanders Date: Tue, 3 Sep 2024 13:21:38 -0400 Subject: [PATCH 7/8] test: unit test for CheckEphemeralSpends --- src/test/txvalidation_tests.cpp | 120 ++++++++++++++++++++++++++++++++ src/test/util/txmempool.cpp | 2 +- src/test/util/txmempool.h | 2 +- 3 files changed, 122 insertions(+), 2 deletions(-) diff --git a/src/test/txvalidation_tests.cpp b/src/test/txvalidation_tests.cpp index 97b27ef370417..808e085917c88 100644 --- a/src/test/txvalidation_tests.cpp +++ b/src/test/txvalidation_tests.cpp @@ -6,6 +6,7 @@ #include #include #include +#include #include #include #include @@ -89,6 +90,125 @@ static inline CTransactionRef make_tx(const std::vector& inputs, int3 return MakeTransactionRef(mtx); } +// Same as make_tx but adds 2 normal outputs and 0-value dust to end of vout +static inline CTransactionRef make_ephemeral_tx(const std::vector& inputs, int32_t version) +{ + CMutableTransaction mtx = CMutableTransaction{}; + mtx.version = version; + mtx.vin.resize(inputs.size()); + mtx.vout.resize(3); + for (size_t i{0}; i < inputs.size(); ++i) { + mtx.vin[i].prevout = inputs[i]; + } + for (auto i{0}; i < 3; ++i) { + mtx.vout[i].scriptPubKey = CScript() << OP_TRUE; + mtx.vout[i].nValue = (i == 2) ? 0 : 10000; + } + return MakeTransactionRef(mtx); +} + +BOOST_FIXTURE_TEST_CASE(ephemeral_tests, RegTestingSetup) +{ + CTxMemPool& pool = *Assert(m_node.mempool); + LOCK2(cs_main, pool.cs); + TestMemPoolEntryHelper entry; + CTxMemPool::setEntries empty_ancestors; + + CFeeRate minrelay(1000); + + // Basic transaction with dust + auto grandparent_tx_1 = make_ephemeral_tx(random_outpoints(1), /*version=*/2); + const auto dust_txid = grandparent_tx_1->GetHash(); + + uint32_t dust_index = 2; + + // Child transaction spending dust + auto dust_spend = make_tx({COutPoint{dust_txid, dust_index}}, /*version=*/2); + + // We first start with nothing "in the mempool", using package checks + + // Trivial single transaction with no dust + BOOST_CHECK(!CheckEphemeralSpends({dust_spend}, minrelay, pool).has_value()); + + // Now with dust, ok because the tx has no dusty parents + BOOST_CHECK(!CheckEphemeralSpends({grandparent_tx_1}, minrelay, pool).has_value()); + + // Dust checks pass + BOOST_CHECK(!CheckEphemeralSpends({grandparent_tx_1, dust_spend}, CFeeRate(0), pool).has_value()); + BOOST_CHECK(!CheckEphemeralSpends({grandparent_tx_1, dust_spend}, minrelay, pool).has_value()); + + auto dust_non_spend = make_tx({COutPoint{dust_txid, dust_index - 1}}, /*version=*/2); + + // Child spending non-dust only from parent should be disallowed even if dust otherwise spent + BOOST_CHECK(CheckEphemeralSpends({grandparent_tx_1, dust_non_spend, dust_spend}, minrelay, pool).has_value()); + BOOST_CHECK(CheckEphemeralSpends({grandparent_tx_1, dust_spend, dust_non_spend}, minrelay, pool).has_value()); + BOOST_CHECK(CheckEphemeralSpends({grandparent_tx_1, dust_non_spend}, minrelay, pool).has_value()); + + auto grandparent_tx_2 = make_ephemeral_tx(random_outpoints(1), /*version=*/2); + const auto dust_txid_2 = grandparent_tx_2->GetHash(); + + // Spend dust from one but not another is ok, as long as second grandparent has no child + BOOST_CHECK(!CheckEphemeralSpends({grandparent_tx_1, grandparent_tx_2, dust_spend}, minrelay, pool).has_value()); + + auto dust_non_spend_both_parents = make_tx({COutPoint{dust_txid, dust_index}, COutPoint{dust_txid_2, dust_index - 1}}, /*version=*/2); + // But if we spend from the parent, it must spend dust + BOOST_CHECK(CheckEphemeralSpends({grandparent_tx_1, grandparent_tx_2, dust_non_spend_both_parents}, minrelay, pool).has_value()); + + auto dust_spend_both_parents = make_tx({COutPoint{dust_txid, dust_index}, COutPoint{dust_txid_2, dust_index}}, /*version=*/2); + BOOST_CHECK(!CheckEphemeralSpends({grandparent_tx_1, grandparent_tx_2, dust_spend_both_parents}, minrelay, pool).has_value()); + + // Spending other outputs is also correct, as long as the dusty one is spent + const std::vector all_outpoints{COutPoint(dust_txid, 0), COutPoint(dust_txid, 1), COutPoint(dust_txid, 2), + COutPoint(dust_txid_2, 0), COutPoint(dust_txid_2, 1), COutPoint(dust_txid_2, 2)}; + auto dust_spend_all_outpoints = make_tx(all_outpoints, /*version=*/2); + BOOST_CHECK(!CheckEphemeralSpends({grandparent_tx_1, grandparent_tx_2, dust_spend_all_outpoints}, minrelay, pool).has_value()); + + // 2 grandparents with dust <- 1 dust-spending parent with dust <- child with no dust + auto parent_with_dust = make_ephemeral_tx({COutPoint{dust_txid, dust_index}, COutPoint{dust_txid_2, dust_index}}, /*version=*/2); + // Ok for parent to have dust + BOOST_CHECK(!CheckEphemeralSpends({grandparent_tx_1, grandparent_tx_2, parent_with_dust}, minrelay, pool).has_value()); + auto child_no_dust = make_tx({COutPoint{parent_with_dust->GetHash(), dust_index}}, /*version=*/2); + BOOST_CHECK(!CheckEphemeralSpends({grandparent_tx_1, grandparent_tx_2, parent_with_dust, child_no_dust}, minrelay, pool).has_value()); + + // 2 grandparents with dust <- 1 dust-spending parent with dust <- child with dust + auto child_with_dust = make_ephemeral_tx({COutPoint{parent_with_dust->GetHash(), dust_index}}, /*version=*/2); + BOOST_CHECK(!CheckEphemeralSpends({grandparent_tx_1, grandparent_tx_2, parent_with_dust, child_with_dust}, minrelay, pool).has_value()); + + // Tests with parents in mempool + + // Nothing in mempool, this should pass for any transaction + BOOST_CHECK(!CheckEphemeralSpends({grandparent_tx_1}, minrelay, pool).has_value()); + + // Add first grandparent to mempool and fetch entry + pool.addUnchecked(entry.FromTx(grandparent_tx_1)); + + // Ignores ancestors that aren't direct parents + BOOST_CHECK(!CheckEphemeralSpends({child_no_dust}, minrelay, pool).has_value()); + + // Valid spend of dust with grandparent in mempool + BOOST_CHECK(!CheckEphemeralSpends({parent_with_dust}, minrelay, pool).has_value()); + + // Second grandparent in same package + BOOST_CHECK(!CheckEphemeralSpends({parent_with_dust, grandparent_tx_2}, minrelay, pool).has_value()); + // Order in package doesn't matter + BOOST_CHECK(!CheckEphemeralSpends({grandparent_tx_2, parent_with_dust}, minrelay, pool).has_value()); + + // Add second grandparent to mempool + pool.addUnchecked(entry.FromTx(grandparent_tx_2)); + + // Only spends single dust out of two direct parents + BOOST_CHECK(CheckEphemeralSpends({dust_non_spend_both_parents}, minrelay, pool).has_value()); + + // Spends both parents' dust + BOOST_CHECK(!CheckEphemeralSpends({parent_with_dust}, minrelay, pool).has_value()); + + // Now add dusty parent to mempool + pool.addUnchecked(entry.FromTx(parent_with_dust)); + + // Passes dust checks even with non-parent ancestors + BOOST_CHECK(!CheckEphemeralSpends({child_no_dust}, minrelay, pool).has_value()); +} + BOOST_FIXTURE_TEST_CASE(version3_tests, RegTestingSetup) { // Test TRUC policy helper functions diff --git a/src/test/util/txmempool.cpp b/src/test/util/txmempool.cpp index 8b7d2708fe67d..0191653ff7a9f 100644 --- a/src/test/util/txmempool.cpp +++ b/src/test/util/txmempool.cpp @@ -141,7 +141,7 @@ std::optional CheckPackageMempoolAcceptResult(const Package& txns, return std::nullopt; } -std::vector GetDustIndexes(const CTransactionRef tx_ref, CFeeRate dust_relay_rate) +std::vector GetDustIndexes(const CTransactionRef& tx_ref, CFeeRate dust_relay_rate) { std::vector dust_indexes; for (size_t i = 0; i < tx_ref->vout.size(); ++i) { diff --git a/src/test/util/txmempool.h b/src/test/util/txmempool.h index 6f657a53eace9..dbbd8e7665a28 100644 --- a/src/test/util/txmempool.h +++ b/src/test/util/txmempool.h @@ -57,7 +57,7 @@ void CheckMempoolEphemeralInvariants(const CTxMemPool& tx_pool); /** Return indexes of the transaction's outputs that are considered dust * at given dust_relay_rate. */ -std::vector GetDustIndexes(const CTransactionRef tx_ref, CFeeRate dust_relay_rate); +std::vector GetDustIndexes(const CTransactionRef& tx_ref, CFeeRate dust_relay_rate); /** For every transaction in tx_pool, check TRUC invariants: * - a TRUC tx's ancestor count must be within TRUC_ANCESTOR_LIMIT From efa9ff7a4984b2f0b550766fcc1a1a3fbf889816 Mon Sep 17 00:00:00 2001 From: Greg Sanders Date: Mon, 21 Oct 2024 15:55:26 -0400 Subject: [PATCH 8/8] bench: Add basic CheckEphemeralSpends benchmark --- src/Makefile.bench.include | 1 + src/bench/mempool_ephemeral_spends.cpp | 83 ++++++++++++++++++++++++++ 2 files changed, 84 insertions(+) create mode 100644 src/bench/mempool_ephemeral_spends.cpp diff --git a/src/Makefile.bench.include b/src/Makefile.bench.include index fe6333d8c0b58..5945e649d829d 100644 --- a/src/Makefile.bench.include +++ b/src/Makefile.bench.include @@ -40,6 +40,7 @@ bench_bench_bitcoin_SOURCES = \ bench/load_external.cpp \ bench/lockedpool.cpp \ bench/logging.cpp \ + bench/mempool_ephemeral_spends.cpp \ bench/mempool_eviction.cpp \ bench/mempool_stress.cpp \ bench/merkle_root.cpp \ diff --git a/src/bench/mempool_ephemeral_spends.cpp b/src/bench/mempool_ephemeral_spends.cpp new file mode 100644 index 0000000000000..e867c61752c1e --- /dev/null +++ b/src/bench/mempool_ephemeral_spends.cpp @@ -0,0 +1,83 @@ +// Copyright (c) 2011-2022 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 +#include +#include +#include +#include +#include