#!/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. """ Test opportunistic 1p1c package submission logic. """ from decimal import Decimal import time from test_framework.mempool_util import ( fill_mempool, ) from test_framework.messages import ( CInv, CTxInWitness, MAX_BIP125_RBF_SEQUENCE, MSG_WTX, msg_inv, msg_tx, tx_from_hex, ) from test_framework.p2p import ( P2PInterface, ) from test_framework.test_framework import BitcoinTestFramework from test_framework.util import ( assert_equal, assert_greater_than, ) from test_framework.wallet import ( MiniWallet, MiniWalletMode, ) # 1sat/vB feerate denominated in BTC/KvB FEERATE_1SAT_VB = Decimal("0.00001000") # Number of seconds to wait to ensure no getdata is received GETDATA_WAIT = 60 def cleanup(func): def wrapper(self, *args, **kwargs): try: func(self, *args, **kwargs) finally: self.nodes[0].disconnect_p2ps() # Do not clear the node's mempool, as each test requires mempool min feerate > min # relay feerate. However, do check that this is the case. assert self.nodes[0].getmempoolinfo()["mempoolminfee"] > self.nodes[0].getnetworkinfo()["relayfee"] # Ensure we do not try to spend the same UTXOs in subsequent tests, as they will look like RBF attempts. self.wallet.rescan_utxos(include_mempool=True) # Resets if mocktime was used self.nodes[0].setmocktime(0) return wrapper class PackageRelayTest(BitcoinTestFramework): def set_test_params(self): self.setup_clean_chain = True self.num_nodes = 1 self.extra_args = [[ "-datacarriersize=100000", "-maxmempool=5", ]] self.supports_cli = False def create_tx_below_mempoolminfee(self, wallet): """Create a 1-input 1sat/vB transaction using a confirmed UTXO. Decrement and use self.sequence so that subsequent calls to this function result in unique transactions.""" self.sequence -= 1 assert_greater_than(self.nodes[0].getmempoolinfo()["mempoolminfee"], FEERATE_1SAT_VB) return wallet.create_self_transfer(fee_rate=FEERATE_1SAT_VB, sequence=self.sequence, confirmed_only=True) @cleanup def test_basic_child_then_parent(self): node = self.nodes[0] self.log.info("Check that opportunistic 1p1c logic works when child is received before parent") low_fee_parent = self.create_tx_below_mempoolminfee(self.wallet) high_fee_child = self.wallet.create_self_transfer(utxo_to_spend=low_fee_parent["new_utxo"], fee_rate=20*FEERATE_1SAT_VB) peer_sender = node.add_p2p_connection(P2PInterface()) # 1. Child is received first (perhaps the low feerate parent didn't meet feefilter or the requests were sent to different nodes). It is missing an input. high_child_wtxid_int = int(high_fee_child["tx"].getwtxid(), 16) peer_sender.send_and_ping(msg_inv([CInv(t=MSG_WTX, h=high_child_wtxid_int)])) peer_sender.wait_for_getdata([high_child_wtxid_int]) peer_sender.send_and_ping(msg_tx(high_fee_child["tx"])) # 2. Node requests the missing parent by txid. parent_txid_int = int(low_fee_parent["txid"], 16) peer_sender.wait_for_getdata([parent_txid_int]) # 3. Sender relays the parent. Parent+Child are evaluated as a package and accepted. peer_sender.send_and_ping(msg_tx(low_fee_parent["tx"])) # 4. Both transactions should now be in mempool. node_mempool = node.getrawmempool() assert low_fee_parent["txid"] in node_mempool assert high_fee_child["txid"] in node_mempool node.disconnect_p2ps() @cleanup def test_basic_parent_then_child(self, wallet): node = self.nodes[0] low_fee_parent = self.create_tx_below_mempoolminfee(wallet) high_fee_child = wallet.create_self_transfer(utxo_to_spend=low_fee_parent["new_utxo"], fee_rate=20*FEERATE_1SAT_VB) peer_sender = node.add_outbound_p2p_connection(P2PInterface(), p2p_idx=1, connection_type="outbound-full-relay") peer_ignored = node.add_outbound_p2p_connection(P2PInterface(), p2p_idx=2, connection_type="outbound-full-relay") # 1. Parent is relayed first. It is too low feerate. parent_wtxid_int = int(low_fee_parent["tx"].getwtxid(), 16) peer_sender.send_and_ping(msg_inv([CInv(t=MSG_WTX, h=parent_wtxid_int)])) peer_sender.wait_for_getdata([parent_wtxid_int]) peer_sender.send_and_ping(msg_tx(low_fee_parent["tx"])) assert low_fee_parent["txid"] not in node.getrawmempool() # Send again from peer_ignored, check that it is ignored peer_ignored.send_and_ping(msg_inv([CInv(t=MSG_WTX, h=parent_wtxid_int)])) assert "getdata" not in peer_ignored.last_message # 2. Child is relayed next. It is missing an input. high_child_wtxid_int = int(high_fee_child["tx"].getwtxid(), 16) peer_sender.send_and_ping(msg_inv([CInv(t=MSG_WTX, h=high_child_wtxid_int)])) peer_sender.wait_for_getdata([high_child_wtxid_int]) peer_sender.send_and_ping(msg_tx(high_fee_child["tx"])) # 3. Node requests the missing parent by txid. # It should do so even if it has previously rejected that parent for being too low feerate. parent_txid_int = int(low_fee_parent["txid"], 16) peer_sender.wait_for_getdata([parent_txid_int]) # 4. Sender re-relays the parent. Parent+Child are evaluated as a package and accepted. peer_sender.send_and_ping(msg_tx(low_fee_parent["tx"])) # 5. Both transactions should now be in mempool. node_mempool = node.getrawmempool() assert low_fee_parent["txid"] in node_mempool assert high_fee_child["txid"] in node_mempool @cleanup def test_low_and_high_child(self, wallet): node = self.nodes[0] low_fee_parent = self.create_tx_below_mempoolminfee(wallet) # This feerate is above mempoolminfee, but not enough to also bump the low feerate parent. feerate_just_above = node.getmempoolinfo()["mempoolminfee"] med_fee_child = wallet.create_self_transfer(utxo_to_spend=low_fee_parent["new_utxo"], fee_rate=feerate_just_above) high_fee_child = wallet.create_self_transfer(utxo_to_spend=low_fee_parent["new_utxo"], fee_rate=999*FEERATE_1SAT_VB) peer_sender = node.add_outbound_p2p_connection(P2PInterface(), p2p_idx=1, connection_type="outbound-full-relay") peer_ignored = node.add_outbound_p2p_connection(P2PInterface(), p2p_idx=2, connection_type="outbound-full-relay") self.log.info("Check that tx caches low fee parent + low fee child package rejections") # 1. Send parent, rejected for being low feerate. parent_wtxid_int = int(low_fee_parent["tx"].getwtxid(), 16) peer_sender.send_and_ping(msg_inv([CInv(t=MSG_WTX, h=parent_wtxid_int)])) peer_sender.wait_for_getdata([parent_wtxid_int]) peer_sender.send_and_ping(msg_tx(low_fee_parent["tx"])) assert low_fee_parent["txid"] not in node.getrawmempool() # Send again from peer_ignored, check that it is ignored peer_ignored.send_and_ping(msg_inv([CInv(t=MSG_WTX, h=parent_wtxid_int)])) assert "getdata" not in peer_ignored.last_message # 2. Send an (orphan) child that has a higher feerate, but not enough to bump the parent. med_child_wtxid_int = int(med_fee_child["tx"].getwtxid(), 16) peer_sender.send_and_ping(msg_inv([CInv(t=MSG_WTX, h=med_child_wtxid_int)])) peer_sender.wait_for_getdata([med_child_wtxid_int]) peer_sender.send_and_ping(msg_tx(med_fee_child["tx"])) # 3. Node requests the orphan's missing parent. parent_txid_int = int(low_fee_parent["txid"], 16) peer_sender.wait_for_getdata([parent_txid_int]) # 4. The low parent + low child are submitted as a package. They are not accepted due to low package feerate. peer_sender.send_and_ping(msg_tx(low_fee_parent["tx"])) assert low_fee_parent["txid"] not in node.getrawmempool() assert med_fee_child["txid"] not in node.getrawmempool() # If peer_ignored announces the low feerate child, it should be ignored peer_ignored.send_and_ping(msg_inv([CInv(t=MSG_WTX, h=med_child_wtxid_int)])) assert "getdata" not in peer_ignored.last_message # If either peer sends the parent again, package evaluation should not be attempted peer_sender.send_and_ping(msg_tx(low_fee_parent["tx"])) peer_ignored.send_and_ping(msg_tx(low_fee_parent["tx"])) assert low_fee_parent["txid"] not in node.getrawmempool() assert med_fee_child["txid"] not in node.getrawmempool() # 5. Send the high feerate (orphan) child high_child_wtxid_int = int(high_fee_child["tx"].getwtxid(), 16) peer_sender.send_and_ping(msg_inv([CInv(t=MSG_WTX, h=high_child_wtxid_int)])) peer_sender.wait_for_getdata([high_child_wtxid_int]) peer_sender.send_and_ping(msg_tx(high_fee_child["tx"])) # 6. Node requests the orphan's parent, even though it has already been rejected, both by # itself and with a child. This is necessary, otherwise high_fee_child can be censored. parent_txid_int = int(low_fee_parent["txid"], 16) peer_sender.wait_for_getdata([parent_txid_int]) # 7. The low feerate parent + high feerate child are submitted as a package. peer_sender.send_and_ping(msg_tx(low_fee_parent["tx"])) # 8. Both transactions should now be in mempool node_mempool = node.getrawmempool() assert low_fee_parent["txid"] in node_mempool assert high_fee_child["txid"] in node_mempool assert med_fee_child["txid"] not in node_mempool @cleanup def test_orphan_consensus_failure(self): self.log.info("Check opportunistic 1p1c logic with consensus-invalid orphan causes disconnect of the correct peer") node = self.nodes[0] low_fee_parent = self.create_tx_below_mempoolminfee(self.wallet) coin = low_fee_parent["new_utxo"] address = node.get_deterministic_priv_key().address # Create raw transaction spending the parent, but with no signature (a consensus error). hex_orphan_no_sig = node.createrawtransaction([{"txid": coin["txid"], "vout": coin["vout"]}], {address : coin["value"] - Decimal("0.0001")}) tx_orphan_bad_wit = tx_from_hex(hex_orphan_no_sig) tx_orphan_bad_wit.wit.vtxinwit.append(CTxInWitness()) tx_orphan_bad_wit.wit.vtxinwit[0].scriptWitness.stack = [b'garbage'] bad_orphan_sender = node.add_p2p_connection(P2PInterface()) parent_sender = node.add_p2p_connection(P2PInterface()) # 1. Child is received first. It is missing an input. child_wtxid_int = int(tx_orphan_bad_wit.getwtxid(), 16) bad_orphan_sender.send_and_ping(msg_inv([CInv(t=MSG_WTX, h=child_wtxid_int)])) bad_orphan_sender.wait_for_getdata([child_wtxid_int]) bad_orphan_sender.send_and_ping(msg_tx(tx_orphan_bad_wit)) # 2. Node requests the missing parent by txid. parent_txid_int = int(low_fee_parent["txid"], 16) bad_orphan_sender.wait_for_getdata([parent_txid_int]) # 3. A different peer relays the parent. Parent+Child are evaluated as a package and rejected. parent_sender.send_message(msg_tx(low_fee_parent["tx"])) # 4. Transactions should not be in mempool. node_mempool = node.getrawmempool() assert low_fee_parent["txid"] not in node_mempool assert tx_orphan_bad_wit.rehash() not in node_mempool # 5. Peer that sent a consensus-invalid transaction should be disconnected. bad_orphan_sender.wait_for_disconnect() # The peer that didn't provide the orphan should not be disconnected. parent_sender.sync_with_ping() @cleanup def test_parent_consensus_failure(self): self.log.info("Check opportunistic 1p1c logic with consensus-invalid parent causes disconnect of the correct peer") node = self.nodes[0] low_fee_parent = self.create_tx_below_mempoolminfee(self.wallet) high_fee_child = self.wallet.create_self_transfer(utxo_to_spend=low_fee_parent["new_utxo"], fee_rate=999*FEERATE_1SAT_VB) # Create invalid version of parent with a bad signature. tx_parent_bad_wit = tx_from_hex(low_fee_parent["hex"]) tx_parent_bad_wit.wit.vtxinwit.append(CTxInWitness()) tx_parent_bad_wit.wit.vtxinwit[0].scriptWitness.stack = [b'garbage'] package_sender = node.add_p2p_connection(P2PInterface()) fake_parent_sender = node.add_p2p_connection(P2PInterface()) # 1. Child is received first. It is missing an input. child_wtxid_int = int(high_fee_child["tx"].getwtxid(), 16) package_sender.send_and_ping(msg_inv([CInv(t=MSG_WTX, h=child_wtxid_int)])) package_sender.wait_for_getdata([child_wtxid_int]) package_sender.send_and_ping(msg_tx(high_fee_child["tx"])) # 2. Node requests the missing parent by txid. parent_txid_int = int(tx_parent_bad_wit.rehash(), 16) package_sender.wait_for_getdata([parent_txid_int]) # 3. A different node relays the parent. The parent is first evaluated by itself and # rejected for being too low feerate. Then it is evaluated as a package and, after passing # feerate checks, rejected for having a bad signature (consensus error). fake_parent_sender.send_message(msg_tx(tx_parent_bad_wit)) # 4. Transactions should not be in mempool. node_mempool = node.getrawmempool() assert tx_parent_bad_wit.rehash() not in node_mempool assert high_fee_child["txid"] not in node_mempool # 5. Peer sent a consensus-invalid transaction. fake_parent_sender.wait_for_disconnect() self.log.info("Check that fake parent does not cause orphan to be deleted and real package can still be submitted") # 6. Child-sending should not have been punished and the orphan should remain in orphanage. # It can send the "real" parent transaction, and the package is accepted. parent_wtxid_int = int(low_fee_parent["tx"].getwtxid(), 16) package_sender.send_and_ping(msg_inv([CInv(t=MSG_WTX, h=parent_wtxid_int)])) package_sender.wait_for_getdata([parent_wtxid_int]) package_sender.send_and_ping(msg_tx(low_fee_parent["tx"])) node_mempool = node.getrawmempool() assert low_fee_parent["txid"] in node_mempool assert high_fee_child["txid"] in node_mempool @cleanup def test_multiple_parents(self): self.log.info("Check that node does not request more than 1 previously-rejected low feerate parent") node = self.nodes[0] node.setmocktime(int(time.time())) # 2-parent-1-child package where both parents are below mempool min feerate parent_low_1 = self.create_tx_below_mempoolminfee(self.wallet_nonsegwit) parent_low_2 = self.create_tx_below_mempoolminfee(self.wallet_nonsegwit) child_bumping = self.wallet_nonsegwit.create_self_transfer_multi( utxos_to_spend=[parent_low_1["new_utxo"], parent_low_2["new_utxo"]], fee_per_output=999*parent_low_1["tx"].get_vsize(), ) peer_sender = node.add_outbound_p2p_connection(P2PInterface(), p2p_idx=1, connection_type="outbound-full-relay") # 1. Send both parents. Each should be rejected for being too low feerate. # Send unsolicited so that we can later check that no "getdata" was ever received. peer_sender.send_and_ping(msg_tx(parent_low_1["tx"])) peer_sender.send_and_ping(msg_tx(parent_low_2["tx"])) # parent_low_1 and parent_low_2 are rejected for being low feerate. assert parent_low_1["txid"] not in node.getrawmempool() assert parent_low_2["txid"] not in node.getrawmempool() # 2. Send child. peer_sender.send_and_ping(msg_tx(child_bumping["tx"])) # 3. Node should not request any parents, as it should recognize that it will not accept # multi-parent-1-child packages. node.bumpmocktime(GETDATA_WAIT) peer_sender.sync_with_ping() assert "getdata" not in peer_sender.last_message @cleanup def test_other_parent_in_mempool(self): self.log.info("Check opportunistic 1p1c fails if child already has another parent in mempool") node = self.nodes[0] # This parent needs CPFP parent_low = self.create_tx_below_mempoolminfee(self.wallet) # This parent does not need CPFP and can be submitted alone ahead of time parent_high = self.wallet.create_self_transfer(fee_rate=FEERATE_1SAT_VB*10, confirmed_only=True) child = self.wallet.create_self_transfer_multi( utxos_to_spend=[parent_high["new_utxo"], parent_low["new_utxo"]], fee_per_output=999*parent_low["tx"].get_vsize(), ) peer_sender = node.add_outbound_p2p_connection(P2PInterface(), p2p_idx=1, connection_type="outbound-full-relay") # 1. Send first parent which will be accepted. peer_sender.send_and_ping(msg_tx(parent_high["tx"])) assert parent_high["txid"] in node.getrawmempool() # 2. Send child. peer_sender.send_and_ping(msg_tx(child["tx"])) # 3. Node requests parent_low. However, 1p1c fails because package-not-child-with-unconfirmed-parents parent_low_txid_int = int(parent_low["txid"], 16) peer_sender.wait_for_getdata([parent_low_txid_int]) peer_sender.send_and_ping(msg_tx(parent_low["tx"])) node_mempool = node.getrawmempool() assert parent_high["txid"] in node_mempool assert parent_low["txid"] not in node_mempool assert child["txid"] not in node_mempool # Same error if submitted through submitpackage without parent_high package_hex_missing_parent = [parent_low["hex"], child["hex"]] result_missing_parent = node.submitpackage(package_hex_missing_parent) assert_equal(result_missing_parent["package_msg"], "package-not-child-with-unconfirmed-parents") def run_test(self): node = self.nodes[0] # To avoid creating transactions with the same txid (can happen if we set the same feerate # and reuse the same input as a previous transaction that wasn't successfully submitted), # we give each subtest a different nSequence for its transactions. self.sequence = MAX_BIP125_RBF_SEQUENCE self.wallet = MiniWallet(node) self.wallet_nonsegwit = MiniWallet(node, mode=MiniWalletMode.RAW_P2PK) self.generate(self.wallet_nonsegwit, 10) self.generate(self.wallet, 20) fill_mempool(self, node) self.log.info("Check opportunistic 1p1c logic when parent (txid != wtxid) is received before child") self.test_basic_parent_then_child(self.wallet) self.log.info("Check opportunistic 1p1c logic when parent (txid == wtxid) is received before child") self.test_basic_parent_then_child(self.wallet_nonsegwit) self.log.info("Check opportunistic 1p1c logic when child is received before parent") self.test_basic_child_then_parent() self.log.info("Check opportunistic 1p1c logic when 2 candidate children exist (parent txid != wtxid)") self.test_low_and_high_child(self.wallet) self.log.info("Check opportunistic 1p1c logic when 2 candidate children exist (parent txid == wtxid)") self.test_low_and_high_child(self.wallet_nonsegwit) self.test_orphan_consensus_failure() self.test_parent_consensus_failure() self.test_multiple_parents() self.test_other_parent_in_mempool() if __name__ == '__main__': PackageRelayTest(__file__).main()