Skip to content

Commit 32c1dd1

Browse files
committed
[test] mempool coins disappearing mid-package evaluation
Test for scenario(s) outlined in PR 28251. Test what happens when a package transaction spends a mempool coin which is fetched and then disappears mid-package evaluation due to eviction or replacement.
1 parent a67f460 commit 32c1dd1

File tree

1 file changed

+163
-1
lines changed

1 file changed

+163
-1
lines changed

test/functional/mempool_limit.py

+163-1
Original file line numberDiff line numberDiff line change
@@ -78,13 +78,172 @@ def fill_mempool(self):
7878
assert_equal(node.getmempoolinfo()['minrelaytxfee'], Decimal('0.00001000'))
7979
assert_greater_than(node.getmempoolinfo()['mempoolminfee'], Decimal('0.00001000'))
8080

81+
def test_mid_package_eviction(self):
82+
node = self.nodes[0]
83+
self.log.info("Check a package where each parent passes the current mempoolminfee but would cause eviction before package submission terminates")
84+
85+
self.restart_node(0, extra_args=self.extra_args[0])
86+
87+
# Restarting the node resets mempool minimum feerate
88+
assert_equal(node.getmempoolinfo()['minrelaytxfee'], Decimal('0.00001000'))
89+
assert_equal(node.getmempoolinfo()['mempoolminfee'], Decimal('0.00001000'))
90+
91+
self.fill_mempool()
92+
current_info = node.getmempoolinfo()
93+
mempoolmin_feerate = current_info["mempoolminfee"]
94+
95+
package_hex = []
96+
# UTXOs to be spent by the ultimate child transaction
97+
parent_utxos = []
98+
99+
evicted_weight = 8000
100+
# Mempool transaction which is evicted due to being at the "bottom" of the mempool when the
101+
# mempool overflows and evicts by descendant score. It's important that the eviction doesn't
102+
# happen in the middle of package evaluation, as it can invalidate the coins cache.
103+
mempool_evicted_tx = self.wallet.send_self_transfer(
104+
from_node=node,
105+
fee=(mempoolmin_feerate / 1000) * (evicted_weight // 4) + Decimal('0.000001'),
106+
target_weight=evicted_weight,
107+
confirmed_only=True
108+
)
109+
# Already in mempool when package is submitted.
110+
assert mempool_evicted_tx["txid"] in node.getrawmempool()
111+
112+
# This parent spends the above mempool transaction that exists when its inputs are first
113+
# looked up, but disappears later. It is rejected for being too low fee (but eligible for
114+
# reconsideration), and its inputs are cached. When the mempool transaction is evicted, its
115+
# coin is no longer available, but the cache could still contains the tx.
116+
cpfp_parent = self.wallet.create_self_transfer(
117+
utxo_to_spend=mempool_evicted_tx["new_utxo"],
118+
fee_rate=mempoolmin_feerate - Decimal('0.00001'),
119+
confirmed_only=True)
120+
package_hex.append(cpfp_parent["hex"])
121+
parent_utxos.append(cpfp_parent["new_utxo"])
122+
assert_equal(node.testmempoolaccept([cpfp_parent["hex"]])[0]["reject-reason"], "mempool min fee not met")
123+
124+
self.wallet.rescan_utxos()
125+
126+
# Series of parents that don't need CPFP and are submitted individually. Each one is large and
127+
# high feerate, which means they should trigger eviction but not be evicted.
128+
parent_weight = 100000
129+
num_big_parents = 3
130+
assert_greater_than(parent_weight * num_big_parents, current_info["maxmempool"] - current_info["bytes"])
131+
parent_fee = (100 * mempoolmin_feerate / 1000) * (parent_weight // 4)
132+
133+
big_parent_txids = []
134+
for i in range(num_big_parents):
135+
parent = self.wallet.create_self_transfer(fee=parent_fee, target_weight=parent_weight, confirmed_only=True)
136+
parent_utxos.append(parent["new_utxo"])
137+
package_hex.append(parent["hex"])
138+
big_parent_txids.append(parent["txid"])
139+
# There is room for each of these transactions independently
140+
assert node.testmempoolaccept([parent["hex"]])[0]["allowed"]
141+
142+
# Create a child spending everything, bumping cpfp_parent just above mempool minimum
143+
# feerate. It's important not to bump too much as otherwise mempool_evicted_tx would not be
144+
# evicted, making this test much less meaningful.
145+
approx_child_vsize = self.wallet.create_self_transfer_multi(utxos_to_spend=parent_utxos)["tx"].get_vsize()
146+
cpfp_fee = (mempoolmin_feerate / 1000) * (cpfp_parent["tx"].get_vsize() + approx_child_vsize) - cpfp_parent["fee"]
147+
# Specific number of satoshis to fit within a small window. The parent_cpfp + child package needs to be
148+
# - When there is mid-package eviction, high enough feerate to meet the new mempoolminfee
149+
# - When there is no mid-package eviction, low enough feerate to be evicted immediately after submission.
150+
magic_satoshis = 1200
151+
cpfp_satoshis = int(cpfp_fee * COIN) + magic_satoshis
152+
153+
child = self.wallet.create_self_transfer_multi(utxos_to_spend=parent_utxos, fee_per_output=cpfp_satoshis)
154+
package_hex.append(child["hex"])
155+
156+
# Package should be submitted, temporarily exceeding maxmempool, and then evicted.
157+
with node.assert_debug_log(expected_msgs=["rolling minimum fee bumped"]):
158+
assert_raises_rpc_error(-26, "mempool full", node.submitpackage, package_hex)
159+
160+
# Maximum size must never be exceeded.
161+
assert_greater_than(node.getmempoolinfo()["maxmempool"], node.getmempoolinfo()["bytes"])
162+
163+
# Evicted transaction and its descendants must not be in mempool.
164+
resulting_mempool_txids = node.getrawmempool()
165+
assert mempool_evicted_tx["txid"] not in resulting_mempool_txids
166+
assert cpfp_parent["txid"] not in resulting_mempool_txids
167+
assert child["txid"] not in resulting_mempool_txids
168+
for txid in big_parent_txids:
169+
assert txid in resulting_mempool_txids
170+
171+
def test_mid_package_replacement(self):
172+
node = self.nodes[0]
173+
self.log.info("Check a package where an early tx depends on a later-replaced mempool tx")
174+
175+
self.restart_node(0, extra_args=self.extra_args[0])
176+
177+
# Restarting the node resets mempool minimum feerate
178+
assert_equal(node.getmempoolinfo()['minrelaytxfee'], Decimal('0.00001000'))
179+
assert_equal(node.getmempoolinfo()['mempoolminfee'], Decimal('0.00001000'))
180+
181+
self.fill_mempool()
182+
current_info = node.getmempoolinfo()
183+
mempoolmin_feerate = current_info["mempoolminfee"]
184+
185+
# Mempool transaction which is evicted due to being at the "bottom" of the mempool when the
186+
# mempool overflows and evicts by descendant score. It's important that the eviction doesn't
187+
# happen in the middle of package evaluation, as it can invalidate the coins cache.
188+
double_spent_utxo = self.wallet.get_utxo(confirmed_only=True)
189+
replaced_tx = self.wallet.send_self_transfer(
190+
from_node=node,
191+
utxo_to_spend=double_spent_utxo,
192+
fee_rate=mempoolmin_feerate,
193+
confirmed_only=True
194+
)
195+
# Already in mempool when package is submitted.
196+
assert replaced_tx["txid"] in node.getrawmempool()
197+
198+
# This parent spends the above mempool transaction that exists when its inputs are first
199+
# looked up, but disappears later. It is rejected for being too low fee (but eligible for
200+
# reconsideration), and its inputs are cached. When the mempool transaction is evicted, its
201+
# coin is no longer available, but the cache could still contain the tx.
202+
cpfp_parent = self.wallet.create_self_transfer(
203+
utxo_to_spend=replaced_tx["new_utxo"],
204+
fee_rate=mempoolmin_feerate - Decimal('0.00001'),
205+
confirmed_only=True)
206+
207+
self.wallet.rescan_utxos()
208+
209+
# Parent that replaces the parent of cpfp_parent.
210+
replacement_tx = self.wallet.create_self_transfer(
211+
utxo_to_spend=double_spent_utxo,
212+
fee_rate=10*mempoolmin_feerate,
213+
confirmed_only=True
214+
)
215+
parent_utxos = [cpfp_parent["new_utxo"], replacement_tx["new_utxo"]]
216+
217+
# Create a child spending everything, CPFPing the low-feerate parent.
218+
approx_child_vsize = self.wallet.create_self_transfer_multi(utxos_to_spend=parent_utxos)["tx"].get_vsize()
219+
cpfp_fee = (2 * mempoolmin_feerate / 1000) * (cpfp_parent["tx"].get_vsize() + approx_child_vsize) - cpfp_parent["fee"]
220+
child = self.wallet.create_self_transfer_multi(utxos_to_spend=parent_utxos, fee_per_output=int(cpfp_fee * COIN))
221+
# It's very important that the cpfp_parent is before replacement_tx so that its input (from
222+
# replaced_tx) is first looked up *before* replacement_tx is submitted.
223+
package_hex = [cpfp_parent["hex"], replacement_tx["hex"], child["hex"]]
224+
225+
# Package should be submitted, temporarily exceeding maxmempool, and then evicted.
226+
assert_raises_rpc_error(-26, "bad-txns-inputs-missingorspent", node.submitpackage, package_hex)
227+
228+
# Maximum size must never be exceeded.
229+
assert_greater_than(node.getmempoolinfo()["maxmempool"], node.getmempoolinfo()["bytes"])
230+
231+
resulting_mempool_txids = node.getrawmempool()
232+
# The replacement should be successful.
233+
assert replacement_tx["txid"] in resulting_mempool_txids
234+
# The replaced tx and all of its descendants must not be in mempool.
235+
assert replaced_tx["txid"] not in resulting_mempool_txids
236+
assert cpfp_parent["txid"] not in resulting_mempool_txids
237+
assert child["txid"] not in resulting_mempool_txids
238+
239+
81240
def run_test(self):
82241
node = self.nodes[0]
83242
self.wallet = MiniWallet(node)
84243
miniwallet = self.wallet
85244

86245
# Generate coins needed to create transactions in the subtests (excluding coins used in fill_mempool).
87-
self.generate(miniwallet, 10)
246+
self.generate(miniwallet, 20)
88247

89248
relayfee = node.getnetworkinfo()['relayfee']
90249
self.log.info('Check that mempoolminfee is minrelaytxfee')
@@ -163,6 +322,9 @@ def run_test(self):
163322
self.stop_node(0)
164323
self.nodes[0].assert_start_raises_init_error(["-maxmempool=4"], "Error: -maxmempool must be at least 5 MB")
165324

325+
self.test_mid_package_replacement()
326+
self.test_mid_package_eviction()
327+
166328

167329
if __name__ == '__main__':
168330
MempoolLimitTest().main()

0 commit comments

Comments
 (0)