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 in faeed687e5 on top of the v3 PR.

ACKs for top commit:
  instagibbs:
    reACK 32c1dd1ad6

Tree-SHA512: 61e7f69db4712e5e5bfa27d037ab66bdd97f1bf60a8d9ffb96adb1f0609af012c810d681102ee5c7baec7b5fe8cb7c304a60c63ccc445d00d86a2b7f0e7ddb90
This commit is contained in:
fanquake
2023-09-13 17:50:21 +01:00
9 changed files with 392 additions and 95 deletions

View File

@@ -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()

View File

@@ -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")

View File

@@ -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