mirror of
https://github.com/bitcoin/bitcoin.git
synced 2026-03-28 16:36:04 +01:00
Merge bitcoin/bitcoin#28251: validation: fix coins disappearing mid-package evaluation
32c1dd1ad6[test] mempool coins disappearing mid-package evaluation (glozow)a67f460c3f[refactor] split setup in mempool_limit test (glozow)d08696120e[test framework] add ability to spend only confirmed utxos (glozow)3ea71feb11[validation] don't LimitMempoolSize in any subpackage submissions (glozow)d227b7234c[validation] return correct result when already-in-mempool tx gets evicted (glozow)9698b81828[refactor] back-fill results in AcceptPackage (glozow)8ad7ad3392[validation] make PackageMempoolAcceptResult members mutable (glozow)03b87c11ca[validation] add AcceptSubPackage to delegate Accept* calls and clean up m_view (glozow)3f01a3dab1[CCoinsViewMemPool] track non-base coins and allow Reset (glozow)7d7f7a1189[policy] check for duplicate txids in package (glozow) Pull request description: While we are evaluating a package, we split it into "subpackages" for evaluation (currently subpackages all have size 1 except the last one). If a subpackage has size 1, we may add a tx to mempool and call `LimitMempoolSize()`, which evicts transactions if the mempool gets full. We handle the case where the just-submitted transaction is evicted immediately, but we don't handle the case in which a transaction from a previous subpackage (either just submitted or already in mempool) is evicted. Mainly, since the coins created by the evicted transaction are cached in `m_view`, we don't realize the UTXO has disappeared until `CheckInputsFromMempoolAndCache` asserts that they exist. Also, the returned `PackageMempoolAcceptResult` reports that the transaction is in mempool even though it isn't anymore. Fix this by not calling `LimitMempoolSize()` until the very end, and editing the results map with "mempool full" if things fall out. Pointed out by instagibbs infaeed687e5on top of the v3 PR. ACKs for top commit: instagibbs: reACK32c1dd1ad6Tree-SHA512: 61e7f69db4712e5e5bfa27d037ab66bdd97f1bf60a8d9ffb96adb1f0609af012c810d681102ee5c7baec7b5fe8cb7c304a60c63ccc445d00d86a2b7f0e7ddb90
This commit is contained in:
@@ -34,29 +34,27 @@ class MempoolLimitTest(BitcoinTestFramework):
|
||||
]]
|
||||
self.supports_cli = False
|
||||
|
||||
def run_test(self):
|
||||
def fill_mempool(self):
|
||||
"""Fill mempool until eviction."""
|
||||
self.log.info("Fill the mempool until eviction is triggered and the mempoolminfee rises")
|
||||
txouts = gen_return_txouts()
|
||||
node = self.nodes[0]
|
||||
miniwallet = MiniWallet(node)
|
||||
miniwallet = self.wallet
|
||||
relayfee = node.getnetworkinfo()['relayfee']
|
||||
|
||||
self.log.info('Check that mempoolminfee is minrelaytxfee')
|
||||
assert_equal(node.getmempoolinfo()['minrelaytxfee'], Decimal('0.00001000'))
|
||||
assert_equal(node.getmempoolinfo()['mempoolminfee'], Decimal('0.00001000'))
|
||||
|
||||
tx_batch_size = 1
|
||||
num_of_batches = 75
|
||||
# Generate UTXOs to flood the mempool
|
||||
# 1 to create a tx initially that will be evicted from the mempool later
|
||||
# 3 batches of multiple transactions with a fee rate much higher than the previous UTXO
|
||||
# 75 transactions each with a fee rate higher than the previous one
|
||||
# And 1 more to verify that this tx does not get added to the mempool with a fee rate less than the mempoolminfee
|
||||
# And 2 more for the package cpfp test
|
||||
self.generate(miniwallet, 1 + (num_of_batches * tx_batch_size) + 1 + 2)
|
||||
self.generate(miniwallet, 1 + (num_of_batches * tx_batch_size))
|
||||
|
||||
# Mine 99 blocks so that the UTXOs are allowed to be spent
|
||||
self.generate(node, COINBASE_MATURITY - 1)
|
||||
|
||||
self.log.info('Create a mempool tx that will be evicted')
|
||||
self.log.debug("Create a mempool tx that will be evicted")
|
||||
tx_to_be_evicted_id = miniwallet.send_self_transfer(from_node=node, fee_rate=relayfee)["txid"]
|
||||
|
||||
# Increase the tx fee rate to give the subsequent transactions a higher priority in the mempool
|
||||
@@ -64,21 +62,196 @@ class MempoolLimitTest(BitcoinTestFramework):
|
||||
# by 130 should result in a fee that corresponds to 2x of that fee rate
|
||||
base_fee = relayfee * 130
|
||||
|
||||
self.log.info("Fill up the mempool with txs with higher fee rate")
|
||||
for batch_of_txid in range(num_of_batches):
|
||||
fee = (batch_of_txid + 1) * base_fee
|
||||
create_lots_of_big_transactions(miniwallet, node, fee, tx_batch_size, txouts)
|
||||
self.log.debug("Fill up the mempool with txs with higher fee rate")
|
||||
with node.assert_debug_log(["rolling minimum fee bumped"]):
|
||||
for batch_of_txid in range(num_of_batches):
|
||||
fee = (batch_of_txid + 1) * base_fee
|
||||
create_lots_of_big_transactions(miniwallet, node, fee, tx_batch_size, txouts)
|
||||
|
||||
self.log.info('The tx should be evicted by now')
|
||||
self.log.debug("The tx should be evicted by now")
|
||||
# The number of transactions created should be greater than the ones present in the mempool
|
||||
assert_greater_than(tx_batch_size * num_of_batches, len(node.getrawmempool()))
|
||||
# Initial tx created should not be present in the mempool anymore as it had a lower fee rate
|
||||
assert tx_to_be_evicted_id not in node.getrawmempool()
|
||||
|
||||
self.log.info('Check that mempoolminfee is larger than minrelaytxfee')
|
||||
self.log.debug("Check that mempoolminfee is larger than minrelaytxfee")
|
||||
assert_equal(node.getmempoolinfo()['minrelaytxfee'], Decimal('0.00001000'))
|
||||
assert_greater_than(node.getmempoolinfo()['mempoolminfee'], Decimal('0.00001000'))
|
||||
|
||||
def test_mid_package_eviction(self):
|
||||
node = self.nodes[0]
|
||||
self.log.info("Check a package where each parent passes the current mempoolminfee but would cause eviction before package submission terminates")
|
||||
|
||||
self.restart_node(0, extra_args=self.extra_args[0])
|
||||
|
||||
# Restarting the node resets mempool minimum feerate
|
||||
assert_equal(node.getmempoolinfo()['minrelaytxfee'], Decimal('0.00001000'))
|
||||
assert_equal(node.getmempoolinfo()['mempoolminfee'], Decimal('0.00001000'))
|
||||
|
||||
self.fill_mempool()
|
||||
current_info = node.getmempoolinfo()
|
||||
mempoolmin_feerate = current_info["mempoolminfee"]
|
||||
|
||||
package_hex = []
|
||||
# UTXOs to be spent by the ultimate child transaction
|
||||
parent_utxos = []
|
||||
|
||||
evicted_weight = 8000
|
||||
# Mempool transaction which is evicted due to being at the "bottom" of the mempool when the
|
||||
# mempool overflows and evicts by descendant score. It's important that the eviction doesn't
|
||||
# happen in the middle of package evaluation, as it can invalidate the coins cache.
|
||||
mempool_evicted_tx = self.wallet.send_self_transfer(
|
||||
from_node=node,
|
||||
fee=(mempoolmin_feerate / 1000) * (evicted_weight // 4) + Decimal('0.000001'),
|
||||
target_weight=evicted_weight,
|
||||
confirmed_only=True
|
||||
)
|
||||
# Already in mempool when package is submitted.
|
||||
assert mempool_evicted_tx["txid"] in node.getrawmempool()
|
||||
|
||||
# This parent spends the above mempool transaction that exists when its inputs are first
|
||||
# looked up, but disappears later. It is rejected for being too low fee (but eligible for
|
||||
# reconsideration), and its inputs are cached. When the mempool transaction is evicted, its
|
||||
# coin is no longer available, but the cache could still contains the tx.
|
||||
cpfp_parent = self.wallet.create_self_transfer(
|
||||
utxo_to_spend=mempool_evicted_tx["new_utxo"],
|
||||
fee_rate=mempoolmin_feerate - Decimal('0.00001'),
|
||||
confirmed_only=True)
|
||||
package_hex.append(cpfp_parent["hex"])
|
||||
parent_utxos.append(cpfp_parent["new_utxo"])
|
||||
assert_equal(node.testmempoolaccept([cpfp_parent["hex"]])[0]["reject-reason"], "mempool min fee not met")
|
||||
|
||||
self.wallet.rescan_utxos()
|
||||
|
||||
# Series of parents that don't need CPFP and are submitted individually. Each one is large and
|
||||
# high feerate, which means they should trigger eviction but not be evicted.
|
||||
parent_weight = 100000
|
||||
num_big_parents = 3
|
||||
assert_greater_than(parent_weight * num_big_parents, current_info["maxmempool"] - current_info["bytes"])
|
||||
parent_fee = (100 * mempoolmin_feerate / 1000) * (parent_weight // 4)
|
||||
|
||||
big_parent_txids = []
|
||||
for i in range(num_big_parents):
|
||||
parent = self.wallet.create_self_transfer(fee=parent_fee, target_weight=parent_weight, confirmed_only=True)
|
||||
parent_utxos.append(parent["new_utxo"])
|
||||
package_hex.append(parent["hex"])
|
||||
big_parent_txids.append(parent["txid"])
|
||||
# There is room for each of these transactions independently
|
||||
assert node.testmempoolaccept([parent["hex"]])[0]["allowed"]
|
||||
|
||||
# Create a child spending everything, bumping cpfp_parent just above mempool minimum
|
||||
# feerate. It's important not to bump too much as otherwise mempool_evicted_tx would not be
|
||||
# evicted, making this test much less meaningful.
|
||||
approx_child_vsize = self.wallet.create_self_transfer_multi(utxos_to_spend=parent_utxos)["tx"].get_vsize()
|
||||
cpfp_fee = (mempoolmin_feerate / 1000) * (cpfp_parent["tx"].get_vsize() + approx_child_vsize) - cpfp_parent["fee"]
|
||||
# Specific number of satoshis to fit within a small window. The parent_cpfp + child package needs to be
|
||||
# - When there is mid-package eviction, high enough feerate to meet the new mempoolminfee
|
||||
# - When there is no mid-package eviction, low enough feerate to be evicted immediately after submission.
|
||||
magic_satoshis = 1200
|
||||
cpfp_satoshis = int(cpfp_fee * COIN) + magic_satoshis
|
||||
|
||||
child = self.wallet.create_self_transfer_multi(utxos_to_spend=parent_utxos, fee_per_output=cpfp_satoshis)
|
||||
package_hex.append(child["hex"])
|
||||
|
||||
# Package should be submitted, temporarily exceeding maxmempool, and then evicted.
|
||||
with node.assert_debug_log(expected_msgs=["rolling minimum fee bumped"]):
|
||||
assert_raises_rpc_error(-26, "mempool full", node.submitpackage, package_hex)
|
||||
|
||||
# Maximum size must never be exceeded.
|
||||
assert_greater_than(node.getmempoolinfo()["maxmempool"], node.getmempoolinfo()["bytes"])
|
||||
|
||||
# Evicted transaction and its descendants must not be in mempool.
|
||||
resulting_mempool_txids = node.getrawmempool()
|
||||
assert mempool_evicted_tx["txid"] not in resulting_mempool_txids
|
||||
assert cpfp_parent["txid"] not in resulting_mempool_txids
|
||||
assert child["txid"] not in resulting_mempool_txids
|
||||
for txid in big_parent_txids:
|
||||
assert txid in resulting_mempool_txids
|
||||
|
||||
def test_mid_package_replacement(self):
|
||||
node = self.nodes[0]
|
||||
self.log.info("Check a package where an early tx depends on a later-replaced mempool tx")
|
||||
|
||||
self.restart_node(0, extra_args=self.extra_args[0])
|
||||
|
||||
# Restarting the node resets mempool minimum feerate
|
||||
assert_equal(node.getmempoolinfo()['minrelaytxfee'], Decimal('0.00001000'))
|
||||
assert_equal(node.getmempoolinfo()['mempoolminfee'], Decimal('0.00001000'))
|
||||
|
||||
self.fill_mempool()
|
||||
current_info = node.getmempoolinfo()
|
||||
mempoolmin_feerate = current_info["mempoolminfee"]
|
||||
|
||||
# Mempool transaction which is evicted due to being at the "bottom" of the mempool when the
|
||||
# mempool overflows and evicts by descendant score. It's important that the eviction doesn't
|
||||
# happen in the middle of package evaluation, as it can invalidate the coins cache.
|
||||
double_spent_utxo = self.wallet.get_utxo(confirmed_only=True)
|
||||
replaced_tx = self.wallet.send_self_transfer(
|
||||
from_node=node,
|
||||
utxo_to_spend=double_spent_utxo,
|
||||
fee_rate=mempoolmin_feerate,
|
||||
confirmed_only=True
|
||||
)
|
||||
# Already in mempool when package is submitted.
|
||||
assert replaced_tx["txid"] in node.getrawmempool()
|
||||
|
||||
# This parent spends the above mempool transaction that exists when its inputs are first
|
||||
# looked up, but disappears later. It is rejected for being too low fee (but eligible for
|
||||
# reconsideration), and its inputs are cached. When the mempool transaction is evicted, its
|
||||
# coin is no longer available, but the cache could still contain the tx.
|
||||
cpfp_parent = self.wallet.create_self_transfer(
|
||||
utxo_to_spend=replaced_tx["new_utxo"],
|
||||
fee_rate=mempoolmin_feerate - Decimal('0.00001'),
|
||||
confirmed_only=True)
|
||||
|
||||
self.wallet.rescan_utxos()
|
||||
|
||||
# Parent that replaces the parent of cpfp_parent.
|
||||
replacement_tx = self.wallet.create_self_transfer(
|
||||
utxo_to_spend=double_spent_utxo,
|
||||
fee_rate=10*mempoolmin_feerate,
|
||||
confirmed_only=True
|
||||
)
|
||||
parent_utxos = [cpfp_parent["new_utxo"], replacement_tx["new_utxo"]]
|
||||
|
||||
# Create a child spending everything, CPFPing the low-feerate parent.
|
||||
approx_child_vsize = self.wallet.create_self_transfer_multi(utxos_to_spend=parent_utxos)["tx"].get_vsize()
|
||||
cpfp_fee = (2 * mempoolmin_feerate / 1000) * (cpfp_parent["tx"].get_vsize() + approx_child_vsize) - cpfp_parent["fee"]
|
||||
child = self.wallet.create_self_transfer_multi(utxos_to_spend=parent_utxos, fee_per_output=int(cpfp_fee * COIN))
|
||||
# It's very important that the cpfp_parent is before replacement_tx so that its input (from
|
||||
# replaced_tx) is first looked up *before* replacement_tx is submitted.
|
||||
package_hex = [cpfp_parent["hex"], replacement_tx["hex"], child["hex"]]
|
||||
|
||||
# Package should be submitted, temporarily exceeding maxmempool, and then evicted.
|
||||
assert_raises_rpc_error(-26, "bad-txns-inputs-missingorspent", node.submitpackage, package_hex)
|
||||
|
||||
# Maximum size must never be exceeded.
|
||||
assert_greater_than(node.getmempoolinfo()["maxmempool"], node.getmempoolinfo()["bytes"])
|
||||
|
||||
resulting_mempool_txids = node.getrawmempool()
|
||||
# The replacement should be successful.
|
||||
assert replacement_tx["txid"] in resulting_mempool_txids
|
||||
# The replaced tx and all of its descendants must not be in mempool.
|
||||
assert replaced_tx["txid"] not in resulting_mempool_txids
|
||||
assert cpfp_parent["txid"] not in resulting_mempool_txids
|
||||
assert child["txid"] not in resulting_mempool_txids
|
||||
|
||||
|
||||
def run_test(self):
|
||||
node = self.nodes[0]
|
||||
self.wallet = MiniWallet(node)
|
||||
miniwallet = self.wallet
|
||||
|
||||
# Generate coins needed to create transactions in the subtests (excluding coins used in fill_mempool).
|
||||
self.generate(miniwallet, 20)
|
||||
|
||||
relayfee = node.getnetworkinfo()['relayfee']
|
||||
self.log.info('Check that mempoolminfee is minrelaytxfee')
|
||||
assert_equal(node.getmempoolinfo()['minrelaytxfee'], Decimal('0.00001000'))
|
||||
assert_equal(node.getmempoolinfo()['mempoolminfee'], Decimal('0.00001000'))
|
||||
|
||||
self.fill_mempool()
|
||||
|
||||
# Deliberately try to create a tx with a fee less than the minimum mempool fee to assert that it does not get added to the mempool
|
||||
self.log.info('Create a mempool tx that will not pass mempoolminfee')
|
||||
assert_raises_rpc_error(-26, "mempool min fee not met", miniwallet.send_self_transfer, from_node=node, fee_rate=relayfee)
|
||||
@@ -149,6 +322,9 @@ class MempoolLimitTest(BitcoinTestFramework):
|
||||
self.stop_node(0)
|
||||
self.nodes[0].assert_start_raises_init_error(["-maxmempool=4"], "Error: -maxmempool must be at least 5 MB")
|
||||
|
||||
self.test_mid_package_replacement()
|
||||
self.test_mid_package_eviction()
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
MempoolLimitTest().main()
|
||||
|
||||
@@ -212,8 +212,8 @@ class RPCPackagesTest(BitcoinTestFramework):
|
||||
coin = self.wallet.get_utxo()
|
||||
|
||||
# tx1 and tx2 share the same inputs
|
||||
tx1 = self.wallet.create_self_transfer(utxo_to_spend=coin)
|
||||
tx2 = self.wallet.create_self_transfer(utxo_to_spend=coin)
|
||||
tx1 = self.wallet.create_self_transfer(utxo_to_spend=coin, fee_rate=DEFAULT_FEE)
|
||||
tx2 = self.wallet.create_self_transfer(utxo_to_spend=coin, fee_rate=2*DEFAULT_FEE)
|
||||
|
||||
# Ensure tx1 and tx2 are valid by themselves
|
||||
assert node.testmempoolaccept([tx1["hex"]])[0]["allowed"]
|
||||
@@ -222,8 +222,8 @@ class RPCPackagesTest(BitcoinTestFramework):
|
||||
self.log.info("Test duplicate transactions in the same package")
|
||||
testres = node.testmempoolaccept([tx1["hex"], tx1["hex"]])
|
||||
assert_equal(testres, [
|
||||
{"txid": tx1["txid"], "wtxid": tx1["wtxid"], "package-error": "conflict-in-package"},
|
||||
{"txid": tx1["txid"], "wtxid": tx1["wtxid"], "package-error": "conflict-in-package"}
|
||||
{"txid": tx1["txid"], "wtxid": tx1["wtxid"], "package-error": "package-contains-duplicates"},
|
||||
{"txid": tx1["txid"], "wtxid": tx1["wtxid"], "package-error": "package-contains-duplicates"}
|
||||
])
|
||||
|
||||
self.log.info("Test conflicting transactions in the same package")
|
||||
|
||||
@@ -208,7 +208,7 @@ class MiniWallet:
|
||||
assert_equal(self._mode, MiniWalletMode.ADDRESS_OP_TRUE)
|
||||
return self._address
|
||||
|
||||
def get_utxo(self, *, txid: str = '', vout: Optional[int] = None, mark_as_spent=True) -> dict:
|
||||
def get_utxo(self, *, txid: str = '', vout: Optional[int] = None, mark_as_spent=True, confirmed_only=False) -> dict:
|
||||
"""
|
||||
Returns a utxo and marks it as spent (pops it from the internal list)
|
||||
|
||||
@@ -224,19 +224,23 @@ class MiniWallet:
|
||||
utxo_filter = reversed(mature_coins) # By default the largest utxo
|
||||
if vout is not None:
|
||||
utxo_filter = filter(lambda utxo: vout == utxo['vout'], utxo_filter)
|
||||
if confirmed_only:
|
||||
utxo_filter = filter(lambda utxo: utxo['confirmations'] > 0, utxo_filter)
|
||||
index = self._utxos.index(next(utxo_filter))
|
||||
if mark_as_spent:
|
||||
return self._utxos.pop(index)
|
||||
else:
|
||||
return self._utxos[index]
|
||||
|
||||
def get_utxos(self, *, include_immature_coinbase=False, mark_as_spent=True):
|
||||
def get_utxos(self, *, include_immature_coinbase=False, mark_as_spent=True, confirmed_only=False):
|
||||
"""Returns the list of all utxos and optionally mark them as spent"""
|
||||
if not include_immature_coinbase:
|
||||
blocks_height = self._test_node.getblockchaininfo()['blocks']
|
||||
utxo_filter = filter(lambda utxo: not utxo['coinbase'] or COINBASE_MATURITY - 1 <= blocks_height - utxo['height'], self._utxos)
|
||||
else:
|
||||
utxo_filter = self._utxos
|
||||
if confirmed_only:
|
||||
utxo_filter = filter(lambda utxo: utxo['confirmations'] > 0, utxo_filter)
|
||||
utxos = deepcopy(list(utxo_filter))
|
||||
if mark_as_spent:
|
||||
self._utxos = []
|
||||
@@ -286,14 +290,15 @@ class MiniWallet:
|
||||
locktime=0,
|
||||
sequence=0,
|
||||
fee_per_output=1000,
|
||||
target_weight=0
|
||||
target_weight=0,
|
||||
confirmed_only=False
|
||||
):
|
||||
"""
|
||||
Create and return a transaction that spends the given UTXOs and creates a
|
||||
certain number of outputs with equal amounts. The output amounts can be
|
||||
set by amount_per_output or automatically calculated with a fee_per_output.
|
||||
"""
|
||||
utxos_to_spend = utxos_to_spend or [self.get_utxo()]
|
||||
utxos_to_spend = utxos_to_spend or [self.get_utxo(confirmed_only=confirmed_only)]
|
||||
sequence = [sequence] * len(utxos_to_spend) if type(sequence) is int else sequence
|
||||
assert_equal(len(utxos_to_spend), len(sequence))
|
||||
|
||||
@@ -333,9 +338,17 @@ class MiniWallet:
|
||||
"tx": tx,
|
||||
}
|
||||
|
||||
def create_self_transfer(self, *, fee_rate=Decimal("0.003"), fee=Decimal("0"), utxo_to_spend=None, locktime=0, sequence=0, target_weight=0):
|
||||
def create_self_transfer(self, *,
|
||||
fee_rate=Decimal("0.003"),
|
||||
fee=Decimal("0"),
|
||||
utxo_to_spend=None,
|
||||
locktime=0,
|
||||
sequence=0,
|
||||
target_weight=0,
|
||||
confirmed_only=False
|
||||
):
|
||||
"""Create and return a tx with the specified fee. If fee is 0, use fee_rate, where the resulting fee may be exact or at most one satoshi higher than needed."""
|
||||
utxo_to_spend = utxo_to_spend or self.get_utxo()
|
||||
utxo_to_spend = utxo_to_spend or self.get_utxo(confirmed_only=confirmed_only)
|
||||
assert fee_rate >= 0
|
||||
assert fee >= 0
|
||||
# calculate fee
|
||||
|
||||
Reference in New Issue
Block a user