diff --git a/test/functional/feature_llmq_is_retroactive.py b/test/functional/feature_llmq_is_retroactive.py index b7497380eebe..6ab716bfa103 100755 --- a/test/functional/feature_llmq_is_retroactive.py +++ b/test/functional/feature_llmq_is_retroactive.py @@ -6,7 +6,8 @@ ''' feature_llmq_is_retroactive.py -Tests retroactive signing +Tests retroactive signing. See feature_llmq_is_retroactive_session_timeout.py +for retroactive signing after the signing session timed out. We have 5 nodes where node 0 is the control node, nodes 1-4 are masternodes. Mempool inconsistencies are simulated via disconnecting/reconnecting node 3 @@ -16,7 +17,6 @@ import time from test_framework.test_framework import DashTestFramework -from test_framework.util import set_node_times class LLMQ_IS_RetroactiveSigning(DashTestFramework): @@ -131,61 +131,5 @@ def run_test(self): assert txid in self.nodes[0].getblock(block, 1)['tx'] self.wait_for_chainlocked_block_all_nodes(block) - self.log.info("testing retroactive signing with partially known TX and session timeout") - self.test_session_timeout(False) - self.log.info("repeating test, but with cycled LLMQs") - self.test_session_timeout(True) - - - def test_session_timeout(self, do_cycle_llmqs): - set_node_times(self.nodes, self.mocktime) - self.isolate_node(3) - rawtx = self.create_fund_sign_tx() - txid_all_nodes = self.nodes[0].sendrawtransaction(rawtx) - txid_all_nodes = self.nodes[3].sendrawtransaction(rawtx) - - rawtx_1 = self.create_fund_sign_tx() - txid_single_node = self.nodes[3].sendrawtransaction(rawtx_1) - - # Make sure connected nodes received the TX before we continue - self.bump_mocktime(30) - self.sync_mempools(self.nodes[:3]) - # Make sure signing is done on nodes 1 and 2 (it's async) - time.sleep(5) - # Make the signing session for the IS lock timeout on nodes 1-3 - self.bump_mocktime(61) - time.sleep(2) # make sure Cleanup() is called - self.reconnect_isolated_node(3, 0) - # Make sure nodes actually try re-connecting quorum connections - self.bump_mocktime(30) - self.wait_for_mnauth(self.nodes[3], 2) - - self.nodes[0].sendrawtransaction(rawtx_1) - # Make sure connected nodes received the TX - self.bump_mocktime(30) - self.sync_mempools(self.nodes[:3]) - self.bump_mocktime(30) - - # Make sure signing is done on nodes 1 and 2 (it's async) - time.sleep(5) - - # node 3 fully reconnected but the signing session is already timed out, so no IS lock - self.assert_no_instantlock(txid_all_nodes, self.nodes[0]) - self.assert_no_instantlock(txid_single_node, self.nodes[0]) - if do_cycle_llmqs: - self.mine_cycle_quorum() - self.mine_cycle_quorum() - self.wait_for_chainlocked_block_all_nodes(self.nodes[0].getbestblockhash(), timeout=30) - - time.sleep(5) - self.assert_no_instantlock(txid_all_nodes, self.nodes[0]) - self.assert_no_instantlock(txid_single_node, self.nodes[0]) - # Make node 0 consider the TX as safe - self.bump_mocktime(10 * 60 + 1) - block = self.generate(self.nodes[0], 1, sync_fun=self.no_op)[0] - assert txid_all_nodes in self.nodes[0].getblock(block, 1)['tx'] - assert txid_single_node in self.nodes[0].getblock(block, 1)['tx'] - self.wait_for_chainlocked_block_all_nodes(block) - if __name__ == '__main__': LLMQ_IS_RetroactiveSigning().main() diff --git a/test/functional/feature_llmq_is_retroactive_session_timeout.py b/test/functional/feature_llmq_is_retroactive_session_timeout.py new file mode 100755 index 000000000000..e6b1f3f6b258 --- /dev/null +++ b/test/functional/feature_llmq_is_retroactive_session_timeout.py @@ -0,0 +1,108 @@ +#!/usr/bin/env python3 +# Copyright (c) 2015-2025 The Dash Core developers +# Distributed under the MIT software license, see the accompanying +# file COPYING or http://www.opensource.org/licenses/mit-license.php. + +''' +feature_llmq_is_retroactive_session_timeout.py + +Tests retroactive signing of a partially known TX once the signing session for +its IS lock timed out, with and without cycling the LLMQs in between. + +We have 5 nodes where node 0 is the control node, nodes 1-4 are masternodes. +Mempool inconsistencies are simulated via disconnecting/reconnecting node 3 +and by having a higher relay fee on node 4. +''' + +import time + +from test_framework.test_framework import DashTestFramework +from test_framework.util import set_node_times + + +class LLMQ_IS_RetroactiveSigningSessionTimeout(DashTestFramework): + def add_options(self, parser): + self.add_wallet_options(parser) + + def set_test_params(self): + # -whitelist is needed to avoid the trickling logic on node0 + self.set_dash_test_params(5, 4, [["-whitelist=127.0.0.1"], [], [], [], ["-minrelaytxfee=0.001"]]) + + def assert_no_instantlock(self, txid, node): + self.log.info(f"Expecting no InstantLock for {txid}") + assert not node.getrawtransaction(txid, True)["instantlock"] + + def create_fund_sign_tx(self): + rawtx = self.nodes[0].createrawtransaction([], {self.nodes[0].getnewaddress(): 1}) + rawtx = self.nodes[0].fundrawtransaction(rawtx)['hex'] + rawtx = self.nodes[0].signrawtransactionwithwallet(rawtx)['hex'] + return rawtx + + def run_test(self): + self.nodes[0].sporkupdate("SPORK_17_QUORUM_DKG_ENABLED", 0) + # Mempool IS signing on + self.nodes[0].sporkupdate("SPORK_2_INSTANTSEND_ENABLED", 0) + self.wait_for_sporks_same() + + self.mine_cycle_quorum() + + # Make sure that all nodes are chainlocked at the same height before starting actual tests + self.wait_for_chainlocked_block_all_nodes(self.nodes[0].getbestblockhash(), timeout=30) + + self.log.info("testing retroactive signing with partially known TX and session timeout") + self.test_session_timeout(False) + self.log.info("repeating test, but with cycled LLMQs") + self.test_session_timeout(True) + + def test_session_timeout(self, do_cycle_llmqs): + set_node_times(self.nodes, self.mocktime) + self.isolate_node(3) + rawtx = self.create_fund_sign_tx() + txid_all_nodes = self.nodes[0].sendrawtransaction(rawtx) + txid_all_nodes = self.nodes[3].sendrawtransaction(rawtx) + + rawtx_1 = self.create_fund_sign_tx() + txid_single_node = self.nodes[3].sendrawtransaction(rawtx_1) + + # Make sure connected nodes received the TX before we continue + self.bump_mocktime(30) + self.sync_mempools(self.nodes[:3]) + # Make sure signing is done on nodes 1 and 2 (it's async) + time.sleep(5) + # Make the signing session for the IS lock timeout on nodes 1-3 + self.bump_mocktime(61) + time.sleep(2) # make sure Cleanup() is called + self.reconnect_isolated_node(3, 0) + # Make sure nodes actually try re-connecting quorum connections + self.bump_mocktime(30) + self.wait_for_mnauth(self.nodes[3], 2) + + self.nodes[0].sendrawtransaction(rawtx_1) + # Make sure connected nodes received the TX + self.bump_mocktime(30) + self.sync_mempools(self.nodes[:3]) + self.bump_mocktime(30) + + # Make sure signing is done on nodes 1 and 2 (it's async) + time.sleep(5) + + # node 3 fully reconnected but the signing session is already timed out, so no IS lock + self.assert_no_instantlock(txid_all_nodes, self.nodes[0]) + self.assert_no_instantlock(txid_single_node, self.nodes[0]) + if do_cycle_llmqs: + self.mine_cycle_quorum() + self.mine_cycle_quorum() + self.wait_for_chainlocked_block_all_nodes(self.nodes[0].getbestblockhash(), timeout=30) + + time.sleep(5) + self.assert_no_instantlock(txid_all_nodes, self.nodes[0]) + self.assert_no_instantlock(txid_single_node, self.nodes[0]) + # Make node 0 consider the TX as safe + self.bump_mocktime(10 * 60 + 1) + block = self.generate(self.nodes[0], 1, sync_fun=self.no_op)[0] + assert txid_all_nodes in self.nodes[0].getblock(block, 1)['tx'] + assert txid_single_node in self.nodes[0].getblock(block, 1)['tx'] + self.wait_for_chainlocked_block_all_nodes(block) + +if __name__ == '__main__': + LLMQ_IS_RetroactiveSigningSessionTimeout().main() diff --git a/test/functional/feature_llmq_simplepose.py b/test/functional/feature_llmq_simplepose.py index 5bd61fe61723..b33f7c7784de 100755 --- a/test/functional/feature_llmq_simplepose.py +++ b/test/functional/feature_llmq_simplepose.py @@ -6,57 +6,25 @@ ''' feature_llmq_simplepose.py -Checks simple PoSe system based on LLMQ commitments +Checks simple PoSe system based on LLMQ commitments: banning of masternodes +that are unreachable. See feature_llmq_simplepose_capabilities.py for +masternodes that run an outdated protocol or lack a required service. ''' -from test_framework.masternodes import check_banned, check_punished -from test_framework.messages import NODE_COMPACT_FILTERS -from test_framework.p2p import P2P_SERVICES, P2PInterface +from test_framework.simplepose import SimplePoSeMixin from test_framework.test_framework import ( DashTestFramework, MasternodeInfo, ) -from test_framework.util import assert_equal, force_finish_mnsync -# See version.h -MIN_MASTERNODE_PROTO_VERSION = 70242 - -class MasternodePeer(P2PInterface): - """Connects to a masternode the way another masternode does, advertising the given relay flag""" - def __init__(self, relay): - super().__init__() - self.version_relay = relay - - def peer_connect_send_version(self, services): - super().peer_connect_send_version(services) - self.on_connection_send_msg.relay = self.version_relay - self.on_connection_send_msg.other_masternode = True - - -class LLMQSimplePoSeTest(DashTestFramework): +class LLMQSimplePoSeTest(SimplePoSeMixin, DashTestFramework): def set_test_params(self): - # rotating quorums add instability for this functional tests - self.extra_args = [[ '-testactivationheight=dip0024@9999' ]] * 6 - self.set_dash_test_params(6, 5) - self.set_dash_llmq_test_params(5, 3) - self.delay_v20_and_mn_rr(height=9999) - - def add_options(self, parser): - self.add_wallet_options(parser) - parser.add_argument("--disable-spork23", dest="disable_spork23", default=False, action="store_true", - help="Test with spork23 disabled") + self.set_simplepose_test_params() def run_test(self): - if self.options.disable_spork23: - self.nodes[0].sporkupdate("SPORK_23_QUORUM_POSE", 4070908800) - else: - self.nodes[0].sporkupdate("SPORK_23_QUORUM_POSE", 0) - - self.deaf_mns = [] - self.nodes[0].sporkupdate("SPORK_17_QUORUM_DKG_ENABLED", 0) - self.wait_for_sporks_same() + self.set_pose_sporks() if not self.options.disable_spork23: # Lets isolate MNs one by one and verify that punishment/banning happens @@ -87,24 +55,7 @@ def run_test(self): # With PoSe off there should be no punishing for non-reachable nodes self.test_no_banning(self.close_mn_port, 3) - - self.deaf_mns.clear() - self.repair_masternodes(True) - - if not self.options.disable_spork23: - self.test_banning(self.force_old_mn_proto, 3) - else: - # With PoSe off there should be no punishing for outdated nodes - self.test_no_banning(self.force_old_mn_proto, 3) - self.repair_masternodes(True) - self.reset_probe_timeouts() - - for relay, services, reason in [(0, P2P_SERVICES | NODE_COMPACT_FILTERS, "does not relay transactions"), - (1, P2P_SERVICES, "does not serve compact block filters")]: - self.test_no_service(relay, services, reason, banned=not self.options.disable_spork23) - self.repair_masternodes(True) - self.reset_probe_timeouts() def isolate_mn(self, mn: MasternodeInfo): mn.get_node(self).setnetworkactive(False) @@ -123,305 +74,6 @@ def close_mn_port(self, mn: MasternodeInfo): self.reset_probe_timeouts() return False, False - def force_old_mn_proto(self, mn: MasternodeInfo): - self.stop_node(mn.nodeIdx) - self.start_masternode(mn, [f"-pushversion={MIN_MASTERNODE_PROTO_VERSION - 1}"]) - self.connect_nodes(mn.nodeIdx, 0) - self.reset_probe_timeouts() - return False, True - - def test_no_service(self, relay, services, reason, banned): - # A member is judged by what our masternode connections to it advertise in their version message. A stock - # masternode always advertises both services, so a P2P peer stands in for the connections to one member. - mn = self.mninfo[0] - node = mn.get_node(self) - others = [m for m in self.mninfo if m is not mn] - expected_complaints = len(others) if banned else 0 - for i in range(2): - self.log.info(f"Testing PoSe {'banning' if banned else 'no banning'} of a masternode that {reason} {i + 1}/2") - self.reset_probe_timeouts() - with others[0].get_node(self).assert_debug_log([reason] if banned else [], unexpected_msgs=[] if banned else [reason]): - self.mine_quorum_no_service(mn, others, relay, services, expected_complaints) - for other in others: - other.get_node(self).disconnect_p2ps() - node.setnetworkactive(True) - force_finish_mnsync(node) - self.connect_nodes(mn.nodeIdx, 0) - self.sync_blocks() - if check_banned(self.nodes[0], mn): - break - assert_equal(check_banned(self.nodes[0], mn), banned) - if not banned: - assert not check_punished(self.nodes[0], mn) - - def mine_quorum_no_service(self, mn, others, relay, services, expected_complaints): - # Like mine_quorum, except that mn goes offline once every member of every type holds its contribution and - # a peer stands in for it on each other member with a masternode connection that lacks the service, so that - # the complain phase judges mn by that connection alone. - self.log.info(f"Mining quorum with a stand-in for {mn.proTxHash}: expected_complaints={expected_complaints}") - nodes = [self.nodes[0]] + [m.get_node(self) for m in others] - spork23_active = self.nodes[0].spork('show')['SPORK_23_QUORUM_POSE'] <= 1 - - # move forward to next DKG - skip_count = 24 - (self.nodes[0].getblockcount() % 24) - self.bump_mocktime(1) - self.generate(self.nodes[0], skip_count) - - q = self.nodes[0].getbestblockhash() - self.log.info("Expected quorum_hash:"+str(q)) - self.log.info("Waiting for phase 1 (init)") - self.wait_for_quorum_phase(q, 1, len(self.mninfo), None, 0, self.mninfo, all_types=True) - self.wait_for_quorum_connections(q, self.llmq_size - 1, self.mninfo, wait_proc=lambda: self.bump_mocktime(1), all_types=True) - if spork23_active: - self.wait_for_masternode_probes(q, others, wait_proc=lambda: self.bump_mocktime(1)) - - self.move_blocks(self.nodes, 2) - - self.log.info("Waiting for phase 2 (contribute)") - self.wait_for_quorum_phase(q, 2, len(self.mninfo), "receivedContributions", self.llmq_size, self.mninfo, all_types=True) - self.wait_for_offset_quorum_phase(q, 2, self.mninfo) - - self.log.info("Replacing the connections to the masternode with stand-ins") - mn.get_node(self).setnetworkactive(False) - for other in others: - other_node = other.get_node(self) - self.wait_until(lambda: all(p.get("verified_proregtx_hash") != mn.proTxHash for p in other_node.getpeerinfo())) - peer = other_node.add_p2p_connection(MasternodePeer(relay), services=services) - peer_ids = [p["id"] for p in other_node.getpeerinfo() if p["subver"] == peer.strSubVer] - assert_equal(len(peer_ids), 1) - assert other_node.mnauth(peer_ids[0], mn.proTxHash, mn.pubKeyOperator) - - self.move_blocks(nodes, 2) - - self.log.info("Waiting for phase 3 (complain)") - self.wait_for_quorum_phase(q, 3, len(others), "receivedComplaints", expected_complaints, others) - self.wait_for_offset_quorum_phase(q, 3, others, messages=False) - - self.move_blocks(nodes, 2) - - self.log.info("Waiting for phase 4 (justify)") - self.wait_for_quorum_phase(q, 4, len(others), "receivedJustifications", 0, others) - self.wait_for_offset_quorum_phase(q, 4, others, messages=False) - - self.move_blocks(nodes, 2) - - self.log.info("Waiting for phase 5 (commit)") - self.wait_for_quorum_phase(q, 5, len(others), "receivedPrematureCommitments", len(others), others) - self.wait_for_offset_quorum_phase(q, 5, others, messages=False) - - self.move_blocks(nodes, 2) - - self.log.info("Waiting for phase 6 (mining)") - self.wait_for_quorum_phase(q, 6, len(others), None, 0, others) - self.wait_for_offset_quorum_phase(q, 6, others, messages=False) - - self.log.info("Waiting final commitment") - self.wait_for_quorum_commitment(q, others) - - self.log.info("Waiting final commitments on mining node") - self.wait_for_quorum_commitments_on_miner(q, others) - - self.log.info("Mining final commitment") - self.bump_mocktime(1) - self.nodes[0].getblocktemplate() # this calls CreateNewBlock - self.generate(self.nodes[0], 1, sync_fun=lambda: self.sync_blocks(nodes)) - - self.log.info("Waiting for quorum to appear in the list") - self.wait_for_quorum_list(q, nodes) - assert_equal(q, self.nodes[0].quorum("list", 1)["llmq_test"][0]) - - self.wait_for_offset_quorum_commitments(q, others) - - # Mine 8 (SIGN_HEIGHT_OFFSET) more blocks to make sure that the new quorum gets eligible for signing sessions - self.generate(self.nodes[0], 8, sync_fun=lambda: self.sync_blocks(nodes)) - - for m in others: - assert not check_punished(self.nodes[0], m) - assert not check_banned(self.nodes[0], m) - - def test_no_banning(self, invalidate_proc, expected_connections=None): - [_, instant_ban] = invalidate_proc(self.mninfo[0]) - iters = 2 if instant_ban else 6 - for i in range(iters): - self.log.info(f"Testing no PoSe banning in normal conditions {i + 1}/{iters}") - self.mine_quorum(expected_connections=expected_connections) - - def mine_quorum_less_checks(self, expected_members, mninfos_online): - # Unlike in mine_quorum we don't know how many complaints and justifications to expect: - # the deaf masternode is a regular DKG participant until enough probes to it have failed, - # so each phase waits for exactly the messages the members report having sent instead. - self.log.info("Mining a quorum with less checks") - nodes = [self.nodes[0]] + [mn.get_node(self) for mn in mninfos_online] - - # move forward to next DKG - skip_count = 24 - (self.nodes[0].getblockcount() % 24) - if skip_count != 0: - self.bump_mocktime(skip_count, nodes=nodes) - self.generate(self.nodes[0], skip_count, sync_fun=lambda: self.sync_blocks(nodes)) - - q = self.nodes[0].getbestblockhash() - self.log.info("Expected quorum_hash: "+str(q)) - self.log.info("Waiting for phase 1 (init)") - self.wait_for_quorum_phase(q, 1, expected_members, None, 0, mninfos_online, all_types=True) - self.wait_for_quorum_connections(q, expected_members - 1, mninfos_online, wait_proc=lambda: self.bump_mocktime(1), all_types=True) - self.log.info("Waiting for this round's probes of the deaf masternodes to be attempted") - self.wait_for_deaf_masternode_probes(mninfos_online) - self.move_blocks(nodes, 2) - - self.log.info("Waiting for phase 2 (contribute)") - self.wait_for_quorum_phase(q, 2, expected_members, "receivedContributions", expected_members, mninfos_online, all_types=True) - self.wait_for_offset_quorum_phase(q, 2, mninfos_online) - self.move_blocks(nodes, 2) - - self.log.info("Waiting for phase 3 (complain)") - self.wait_for_quorum_phase(q, 3, expected_members, "receivedComplaints", None, mninfos_online, all_types=True) - self.wait_for_offset_quorum_phase(q, 3, mninfos_online) - self.move_blocks(nodes, 2) - - self.log.info("Waiting for phase 4 (justify)") - self.wait_for_quorum_phase(q, 4, expected_members, "receivedJustifications", None, mninfos_online, all_types=True) - self.wait_for_offset_quorum_phase(q, 4, mninfos_online) - self.move_blocks(nodes, 2) - - self.log.info("Waiting for phase 5 (commit)") - self.wait_for_quorum_phase(q, 5, expected_members, "receivedPrematureCommitments", expected_members, mninfos_online, all_types=True) - self.wait_for_offset_quorum_phase(q, 5, mninfos_online) - self.move_blocks(nodes, 2) - - self.log.info("Waiting for phase 6 (mining)") - self.wait_for_quorum_phase(q, 6, expected_members, None, 0, mninfos_online, all_types=True) - self.wait_for_offset_quorum_phase(q, 6, mninfos_online) - - self.log.info("Waiting final commitment") - self.wait_for_quorum_commitment(q, mninfos_online) - - self.log.info("Waiting final commitments on mining node") - self.wait_for_quorum_commitments_on_miner(q, mninfos_online) - - self.log.info("Mining final commitment") - self.bump_mocktime(1, nodes=nodes) - self.nodes[0].getblocktemplate() # this calls CreateNewBlock - self.generate(self.nodes[0], 1, sync_fun=lambda: self.sync_blocks(nodes)) - - self.log.info("Waiting for quorum to appear in the list") - self.wait_for_quorum_list(q, nodes) - - new_quorum = self.nodes[0].quorum("list", 1)["llmq_test"][0] - assert_equal(q, new_quorum) - quorum_info = self.nodes[0].quorum("info", 100, new_quorum) - - self.wait_for_offset_quorum_commitments(q, mninfos_online) - - # Mine 8 (SIGN_HEIGHT_OFFSET) more blocks to make sure that the new quorum gets eligible for signing sessions - self.bump_mocktime(8) - self.generate(self.nodes[0], 8, sync_fun=lambda: self.sync_blocks(nodes)) - self.log.info("New quorum: height=%d, quorumHash=%s, quorumIndex=%d, minedBlock=%s" % (quorum_info["height"], new_quorum, quorum_info["quorumIndex"], quorum_info["minedBlock"])) - - return new_quorum - - def wait_for_deaf_masternode_probes(self, mninfos_online): - # A member marks a deaf masternode bad only once enough probes of it have failed, and a - # probe attempt is counted when the connection is opened, not when the DKG round starts. - # reset_probe_timeouts() moved mocktime 10 minutes ahead of any previous attempt, so a - # younger attempt is this round's one. Already banned deaf masternodes are no members - # and are not probed anymore. - probed_mns = [mn for mn in self.deaf_mns if not check_banned(self.nodes[0], mn)] - - def probes_attempted(): - for mn in mninfos_online: - if mn in self.deaf_mns: - continue - for probed_mn in probed_mns: - if mn.get_node(self).protx('info', probed_mn.proTxHash)['metaInfo']['lastOutboundAttemptElapsed'] > 10 * 60: - return False - return True - - self.wait_until(probes_attempted) - - def test_banning(self, invalidate_proc, expected_connections=None): - mninfos_online = self.mninfo.copy() - mninfos_valid = self.mninfo.copy() - - for mn in mninfos_valid: - assert not check_punished(self.nodes[0], mn) - assert not check_banned(self.nodes[0], mn) - - expected_contributors = len(mninfos_online) - for i in range(2): - self.log.info(f"Testing PoSe banning due to {invalidate_proc.__name__} {i + 1}/2") - mn: MasternodeInfo = mninfos_valid.pop() - went_offline, instant_ban = invalidate_proc(mn) - expected_complaints = expected_contributors - 1 - if went_offline: - mninfos_online.remove(mn) - expected_contributors -= 1 - - # NOTE: Min PoSe penalty is 100 (see CDeterministicMNList::CalcMaxPoSePenalty()), - # so nodes are PoSe-banned in the same DKG they misbehave without being PoSe-punished first. - if instant_ban: - assert expected_connections is not None - self.log.info("Expecting instant PoSe banning") - self.reset_probe_timeouts() - self.mine_quorum(expected_connections=expected_connections, expected_members=expected_contributors, expected_contributions=expected_contributors, expected_complaints=expected_complaints, expected_commitments=expected_contributors, mninfos_online=mninfos_online, mninfos_valid=mninfos_valid) - - if not check_banned(self.nodes[0], mn): - self.log.info("Instant ban still requires 2 missing DKG round. If it is not banned yet, mine 2nd one") - self.reset_probe_timeouts() - self.mine_quorum(expected_connections=expected_connections, expected_members=expected_contributors, expected_contributions=expected_contributors, expected_complaints=expected_complaints, expected_commitments=expected_contributors, mninfos_online=mninfos_online, mninfos_valid=mninfos_valid) - else: - # A member is marked bad once more than MASTERNODE_MAX_FAILED_OUTBOUND_ATTEMPTS (5) - # probes of it failed, i.e. from the 6th DKG round on with one probe per round, and - # each exclusion costs 66% of the ban threshold, so the 7th round bans it for sure. - assert expected_connections is None - for j in range(7): - self.log.info(f"Accumulating PoSe penalty {j + 1}/7") - self.reset_probe_timeouts() - self.mine_quorum_less_checks(expected_contributors, mninfos_online) - if check_banned(self.nodes[0], mn): - break - - assert check_banned(self.nodes[0], mn) - - if not went_offline: - # we do not include PoSe banned mns in quorums, so the next one should have 1 contributor less - expected_contributors -= 1 - - def repair_masternodes(self, restart): - self.log.info("Repairing all banned and punished masternodes") - repaired = [mn for mn in self.mninfo if check_banned(self.nodes[0], mn) or check_punished(self.nodes[0], mn)] - for mn in repaired: - addr = self.nodes[0].getnewaddress() - self.nodes[0].sendtoaddress(addr, 0.1) - mn.update_service(self.nodes[0], submit=True, fundsAddr=addr) - if restart: - self.restart_masternodes(mns=repaired) - else: - for mn in repaired: - mn.get_node(self).setnetworkactive(True) - self.connect_nodes(mn.nodeIdx, 0) - - # syncing blocks only since node 0 has txes waiting to be mined - self.sync_blocks() - - # Make sure protxes are "safe" to mine even when InstantSend and ChainLocks are no longer functional - self.bump_mocktime(60 * 10 + 1) - self.generate(self.nodes[0], 1) - - # Isolate and re-connect all MNs (otherwise there might be open connections with no MNAUTH for MNs which were banned before) - for mn in self.mninfo: # type: MasternodeInfo - assert not check_banned(self.nodes[0], mn) - mn.get_node(self).setnetworkactive(False) - self.wait_until(lambda: mn.get_node(self).getconnectioncount() == 0) - # node0 has to notice the drop as well, or it rejects the fresh MNAUTH as a duplicate of the stale peer - self.wait_until(lambda: all(p.get("verified_proregtx_hash") != mn.proTxHash for p in self.nodes[0].getpeerinfo())) - mn.get_node(self).setnetworkactive(True) - force_finish_mnsync(mn.get_node(self)) - self.connect_nodes(mn.nodeIdx, 0) - - def reset_probe_timeouts(self): - # Make sure all masternodes will reconnect/re-probe - self.bump_mocktime(10 * 60 + 1) - if __name__ == '__main__': LLMQSimplePoSeTest().main() diff --git a/test/functional/feature_llmq_simplepose_capabilities.py b/test/functional/feature_llmq_simplepose_capabilities.py new file mode 100755 index 000000000000..3f54a8592cc3 --- /dev/null +++ b/test/functional/feature_llmq_simplepose_capabilities.py @@ -0,0 +1,186 @@ +#!/usr/bin/env python3 +# Copyright (c) 2015-2025 The Dash Core developers +# Distributed under the MIT software license, see the accompanying +# file COPYING or http://www.opensource.org/licenses/mit-license.php. + +''' +feature_llmq_simplepose_capabilities.py + +Checks simple PoSe system based on LLMQ commitments: banning of masternodes +that run an outdated protocol or lack a required service. + +''' + +from test_framework.masternodes import check_banned, check_punished +from test_framework.messages import NODE_COMPACT_FILTERS +from test_framework.p2p import P2P_SERVICES, P2PInterface +from test_framework.simplepose import SimplePoSeMixin +from test_framework.test_framework import ( + DashTestFramework, + MasternodeInfo, +) +from test_framework.util import assert_equal, force_finish_mnsync + +# See version.h +MIN_MASTERNODE_PROTO_VERSION = 70242 + + +class MasternodePeer(P2PInterface): + """Connects to a masternode the way another masternode does, advertising the given relay flag""" + def __init__(self, relay): + super().__init__() + self.version_relay = relay + + def peer_connect_send_version(self, services): + super().peer_connect_send_version(services) + self.on_connection_send_msg.relay = self.version_relay + self.on_connection_send_msg.other_masternode = True + + +class LLMQSimplePoSeCapabilitiesTest(SimplePoSeMixin, DashTestFramework): + def set_test_params(self): + self.set_simplepose_test_params() + + def run_test(self): + self.set_pose_sporks() + + # These scenarios need SPORK_21 on, an existing quorum and all masternodes healthy + self.nodes[0].sporkupdate("SPORK_21_QUORUM_ALL_CONNECTED", 0) + self.wait_for_sporks_same() + self.reset_probe_timeouts() + self.mine_quorum() + + if not self.options.disable_spork23: + self.test_banning(self.force_old_mn_proto, 3) + else: + # With PoSe off there should be no punishing for outdated nodes + self.test_no_banning(self.force_old_mn_proto, 3) + + self.repair_masternodes(True) + self.reset_probe_timeouts() + + for relay, services, reason in [(0, P2P_SERVICES | NODE_COMPACT_FILTERS, "does not relay transactions"), + (1, P2P_SERVICES, "does not serve compact block filters")]: + self.test_no_service(relay, services, reason, banned=not self.options.disable_spork23) + self.repair_masternodes(True) + self.reset_probe_timeouts() + + def force_old_mn_proto(self, mn: MasternodeInfo): + self.stop_node(mn.nodeIdx) + self.start_masternode(mn, [f"-pushversion={MIN_MASTERNODE_PROTO_VERSION - 1}"]) + self.connect_nodes(mn.nodeIdx, 0) + self.reset_probe_timeouts() + return False, True + + def test_no_service(self, relay, services, reason, banned): + # A member is judged by what our masternode connections to it advertise in their version message. A stock + # masternode always advertises both services, so a P2P peer stands in for the connections to one member. + mn = self.mninfo[0] + node = mn.get_node(self) + others = [m for m in self.mninfo if m is not mn] + expected_complaints = len(others) if banned else 0 + for i in range(2): + self.log.info(f"Testing PoSe {'banning' if banned else 'no banning'} of a masternode that {reason} {i + 1}/2") + self.reset_probe_timeouts() + with others[0].get_node(self).assert_debug_log([reason] if banned else [], unexpected_msgs=[] if banned else [reason]): + self.mine_quorum_no_service(mn, others, relay, services, expected_complaints) + for other in others: + other.get_node(self).disconnect_p2ps() + node.setnetworkactive(True) + force_finish_mnsync(node) + self.connect_nodes(mn.nodeIdx, 0) + self.sync_blocks() + if check_banned(self.nodes[0], mn): + break + assert_equal(check_banned(self.nodes[0], mn), banned) + if not banned: + assert not check_punished(self.nodes[0], mn) + + def mine_quorum_no_service(self, mn, others, relay, services, expected_complaints): + # Like mine_quorum, except that mn goes offline once every member of every type holds its contribution and + # a peer stands in for it on each other member with a masternode connection that lacks the service, so that + # the complain phase judges mn by that connection alone. + self.log.info(f"Mining quorum with a stand-in for {mn.proTxHash}: expected_complaints={expected_complaints}") + nodes = [self.nodes[0]] + [m.get_node(self) for m in others] + spork23_active = self.nodes[0].spork('show')['SPORK_23_QUORUM_POSE'] <= 1 + + # move forward to next DKG + skip_count = 24 - (self.nodes[0].getblockcount() % 24) + self.bump_mocktime(1) + self.generate(self.nodes[0], skip_count) + + q = self.nodes[0].getbestblockhash() + self.log.info("Expected quorum_hash:"+str(q)) + self.log.info("Waiting for phase 1 (init)") + self.wait_for_quorum_phase(q, 1, len(self.mninfo), None, 0, self.mninfo, all_types=True) + self.wait_for_quorum_connections(q, self.llmq_size - 1, self.mninfo, wait_proc=lambda: self.bump_mocktime(1), all_types=True) + if spork23_active: + self.wait_for_masternode_probes(q, others, wait_proc=lambda: self.bump_mocktime(1)) + + self.move_blocks(self.nodes, 2) + + self.log.info("Waiting for phase 2 (contribute)") + self.wait_for_quorum_phase(q, 2, len(self.mninfo), "receivedContributions", self.llmq_size, self.mninfo, all_types=True) + self.wait_for_offset_quorum_phase(q, 2, self.mninfo) + + self.log.info("Replacing the connections to the masternode with stand-ins") + mn.get_node(self).setnetworkactive(False) + for other in others: + other_node = other.get_node(self) + self.wait_until(lambda: all(p.get("verified_proregtx_hash") != mn.proTxHash for p in other_node.getpeerinfo())) + peer = other_node.add_p2p_connection(MasternodePeer(relay), services=services) + peer_ids = [p["id"] for p in other_node.getpeerinfo() if p["subver"] == peer.strSubVer] + assert_equal(len(peer_ids), 1) + assert other_node.mnauth(peer_ids[0], mn.proTxHash, mn.pubKeyOperator) + + self.move_blocks(nodes, 2) + + self.log.info("Waiting for phase 3 (complain)") + self.wait_for_quorum_phase(q, 3, len(others), "receivedComplaints", expected_complaints, others) + self.wait_for_offset_quorum_phase(q, 3, others, messages=False) + + self.move_blocks(nodes, 2) + + self.log.info("Waiting for phase 4 (justify)") + self.wait_for_quorum_phase(q, 4, len(others), "receivedJustifications", 0, others) + self.wait_for_offset_quorum_phase(q, 4, others, messages=False) + + self.move_blocks(nodes, 2) + + self.log.info("Waiting for phase 5 (commit)") + self.wait_for_quorum_phase(q, 5, len(others), "receivedPrematureCommitments", len(others), others) + self.wait_for_offset_quorum_phase(q, 5, others, messages=False) + + self.move_blocks(nodes, 2) + + self.log.info("Waiting for phase 6 (mining)") + self.wait_for_quorum_phase(q, 6, len(others), None, 0, others) + self.wait_for_offset_quorum_phase(q, 6, others, messages=False) + + self.log.info("Waiting final commitment") + self.wait_for_quorum_commitment(q, others) + + self.log.info("Waiting final commitments on mining node") + self.wait_for_quorum_commitments_on_miner(q, others) + + self.log.info("Mining final commitment") + self.bump_mocktime(1) + self.nodes[0].getblocktemplate() # this calls CreateNewBlock + self.generate(self.nodes[0], 1, sync_fun=lambda: self.sync_blocks(nodes)) + + self.log.info("Waiting for quorum to appear in the list") + self.wait_for_quorum_list(q, nodes) + assert_equal(q, self.nodes[0].quorum("list", 1)["llmq_test"][0]) + + self.wait_for_offset_quorum_commitments(q, others) + + # Mine 8 (SIGN_HEIGHT_OFFSET) more blocks to make sure that the new quorum gets eligible for signing sessions + self.generate(self.nodes[0], 8, sync_fun=lambda: self.sync_blocks(nodes)) + + for m in others: + assert not check_punished(self.nodes[0], m) + assert not check_banned(self.nodes[0], m) + + +if __name__ == '__main__': + LLMQSimplePoSeCapabilitiesTest().main() diff --git a/test/functional/test_framework/simplepose.py b/test/functional/test_framework/simplepose.py new file mode 100644 index 000000000000..286c8d1b627d --- /dev/null +++ b/test/functional/test_framework/simplepose.py @@ -0,0 +1,217 @@ +#!/usr/bin/env python3 +# Copyright (c) 2015-2025 The Dash Core developers +# Distributed under the MIT software license, see the accompanying +# file COPYING or http://www.opensource.org/licenses/mit-license.php. +"""Shared setup and PoSe banning helpers for the feature_llmq_simplepose*.py tests.""" + +from test_framework.masternodes import check_banned, check_punished +from test_framework.test_framework import MasternodeInfo +from test_framework.util import assert_equal, force_finish_mnsync + + +class SimplePoSeMixin: + """Mixed into the DashTestFramework subclasses of the feature_llmq_simplepose*.py tests""" + def set_simplepose_test_params(self): + # rotating quorums add instability for this functional tests + self.extra_args = [[ '-testactivationheight=dip0024@9999' ]] * 6 + self.set_dash_test_params(6, 5) + self.set_dash_llmq_test_params(5, 3) + self.delay_v20_and_mn_rr(height=9999) + self.deaf_mns = [] + + def add_options(self, parser): + self.add_wallet_options(parser) + parser.add_argument("--disable-spork23", dest="disable_spork23", default=False, action="store_true", + help="Test with spork23 disabled") + + def set_pose_sporks(self): + if self.options.disable_spork23: + self.nodes[0].sporkupdate("SPORK_23_QUORUM_POSE", 4070908800) + else: + self.nodes[0].sporkupdate("SPORK_23_QUORUM_POSE", 0) + + self.nodes[0].sporkupdate("SPORK_17_QUORUM_DKG_ENABLED", 0) + self.wait_for_sporks_same() + + def test_no_banning(self, invalidate_proc, expected_connections=None): + [_, instant_ban] = invalidate_proc(self.mninfo[0]) + iters = 2 if instant_ban else 6 + for i in range(iters): + self.log.info(f"Testing no PoSe banning in normal conditions {i + 1}/{iters}") + self.mine_quorum(expected_connections=expected_connections) + + def mine_quorum_less_checks(self, expected_members, mninfos_online): + # Unlike in mine_quorum we don't know how many complaints and justifications to expect: + # the deaf masternode is a regular DKG participant until enough probes to it have failed, + # so each phase waits for exactly the messages the members report having sent instead. + self.log.info("Mining a quorum with less checks") + nodes = [self.nodes[0]] + [mn.get_node(self) for mn in mninfos_online] + + # move forward to next DKG + skip_count = 24 - (self.nodes[0].getblockcount() % 24) + if skip_count != 0: + self.bump_mocktime(skip_count, nodes=nodes) + self.generate(self.nodes[0], skip_count, sync_fun=lambda: self.sync_blocks(nodes)) + + q = self.nodes[0].getbestblockhash() + self.log.info("Expected quorum_hash: "+str(q)) + self.log.info("Waiting for phase 1 (init)") + self.wait_for_quorum_phase(q, 1, expected_members, None, 0, mninfos_online, all_types=True) + self.wait_for_quorum_connections(q, expected_members - 1, mninfos_online, wait_proc=lambda: self.bump_mocktime(1), all_types=True) + self.log.info("Waiting for this round's probes of the deaf masternodes to be attempted") + self.wait_for_deaf_masternode_probes(mninfos_online) + self.move_blocks(nodes, 2) + + self.log.info("Waiting for phase 2 (contribute)") + self.wait_for_quorum_phase(q, 2, expected_members, "receivedContributions", expected_members, mninfos_online, all_types=True) + self.wait_for_offset_quorum_phase(q, 2, mninfos_online) + self.move_blocks(nodes, 2) + + self.log.info("Waiting for phase 3 (complain)") + self.wait_for_quorum_phase(q, 3, expected_members, "receivedComplaints", None, mninfos_online, all_types=True) + self.wait_for_offset_quorum_phase(q, 3, mninfos_online) + self.move_blocks(nodes, 2) + + self.log.info("Waiting for phase 4 (justify)") + self.wait_for_quorum_phase(q, 4, expected_members, "receivedJustifications", None, mninfos_online, all_types=True) + self.wait_for_offset_quorum_phase(q, 4, mninfos_online) + self.move_blocks(nodes, 2) + + self.log.info("Waiting for phase 5 (commit)") + self.wait_for_quorum_phase(q, 5, expected_members, "receivedPrematureCommitments", expected_members, mninfos_online, all_types=True) + self.wait_for_offset_quorum_phase(q, 5, mninfos_online) + self.move_blocks(nodes, 2) + + self.log.info("Waiting for phase 6 (mining)") + self.wait_for_quorum_phase(q, 6, expected_members, None, 0, mninfos_online, all_types=True) + self.wait_for_offset_quorum_phase(q, 6, mninfos_online) + + self.log.info("Waiting final commitment") + self.wait_for_quorum_commitment(q, mninfos_online) + + self.log.info("Waiting final commitments on mining node") + self.wait_for_quorum_commitments_on_miner(q, mninfos_online) + + self.log.info("Mining final commitment") + self.bump_mocktime(1, nodes=nodes) + self.nodes[0].getblocktemplate() # this calls CreateNewBlock + self.generate(self.nodes[0], 1, sync_fun=lambda: self.sync_blocks(nodes)) + + self.log.info("Waiting for quorum to appear in the list") + self.wait_for_quorum_list(q, nodes) + + new_quorum = self.nodes[0].quorum("list", 1)["llmq_test"][0] + assert_equal(q, new_quorum) + quorum_info = self.nodes[0].quorum("info", 100, new_quorum) + + self.wait_for_offset_quorum_commitments(q, mninfos_online) + + # Mine 8 (SIGN_HEIGHT_OFFSET) more blocks to make sure that the new quorum gets eligible for signing sessions + self.bump_mocktime(8) + self.generate(self.nodes[0], 8, sync_fun=lambda: self.sync_blocks(nodes)) + self.log.info("New quorum: height=%d, quorumHash=%s, quorumIndex=%d, minedBlock=%s" % (quorum_info["height"], new_quorum, quorum_info["quorumIndex"], quorum_info["minedBlock"])) + + return new_quorum + + def wait_for_deaf_masternode_probes(self, mninfos_online): + # A member marks a deaf masternode bad only once enough probes of it have failed, and a + # probe attempt is counted when the connection is opened, not when the DKG round starts. + # reset_probe_timeouts() moved mocktime 10 minutes ahead of any previous attempt, so a + # younger attempt is this round's one. Already banned deaf masternodes are no members + # and are not probed anymore. + probed_mns = [mn for mn in self.deaf_mns if not check_banned(self.nodes[0], mn)] + + def probes_attempted(): + for mn in mninfos_online: + if mn in self.deaf_mns: + continue + for probed_mn in probed_mns: + if mn.get_node(self).protx('info', probed_mn.proTxHash)['metaInfo']['lastOutboundAttemptElapsed'] > 10 * 60: + return False + return True + + self.wait_until(probes_attempted) + + def test_banning(self, invalidate_proc, expected_connections=None): + mninfos_online = self.mninfo.copy() + mninfos_valid = self.mninfo.copy() + + for mn in mninfos_valid: + assert not check_punished(self.nodes[0], mn) + assert not check_banned(self.nodes[0], mn) + + expected_contributors = len(mninfos_online) + for i in range(2): + self.log.info(f"Testing PoSe banning due to {invalidate_proc.__name__} {i + 1}/2") + mn: MasternodeInfo = mninfos_valid.pop() + went_offline, instant_ban = invalidate_proc(mn) + expected_complaints = expected_contributors - 1 + if went_offline: + mninfos_online.remove(mn) + expected_contributors -= 1 + + # NOTE: Min PoSe penalty is 100 (see CDeterministicMNList::CalcMaxPoSePenalty()), + # so nodes are PoSe-banned in the same DKG they misbehave without being PoSe-punished first. + if instant_ban: + assert expected_connections is not None + self.log.info("Expecting instant PoSe banning") + self.reset_probe_timeouts() + self.mine_quorum(expected_connections=expected_connections, expected_members=expected_contributors, expected_contributions=expected_contributors, expected_complaints=expected_complaints, expected_commitments=expected_contributors, mninfos_online=mninfos_online, mninfos_valid=mninfos_valid) + + if not check_banned(self.nodes[0], mn): + self.log.info("Instant ban still requires 2 missing DKG round. If it is not banned yet, mine 2nd one") + self.reset_probe_timeouts() + self.mine_quorum(expected_connections=expected_connections, expected_members=expected_contributors, expected_contributions=expected_contributors, expected_complaints=expected_complaints, expected_commitments=expected_contributors, mninfos_online=mninfos_online, mninfos_valid=mninfos_valid) + else: + # A member is marked bad once more than MASTERNODE_MAX_FAILED_OUTBOUND_ATTEMPTS (5) + # probes of it failed, i.e. from the 6th DKG round on with one probe per round, and + # each exclusion costs 66% of the ban threshold, so the 7th round bans it for sure. + assert expected_connections is None + for j in range(7): + self.log.info(f"Accumulating PoSe penalty {j + 1}/7") + self.reset_probe_timeouts() + self.mine_quorum_less_checks(expected_contributors, mninfos_online) + if check_banned(self.nodes[0], mn): + break + + assert check_banned(self.nodes[0], mn) + + if not went_offline: + # we do not include PoSe banned mns in quorums, so the next one should have 1 contributor less + expected_contributors -= 1 + + def repair_masternodes(self, restart): + self.log.info("Repairing all banned and punished masternodes") + repaired = [mn for mn in self.mninfo if check_banned(self.nodes[0], mn) or check_punished(self.nodes[0], mn)] + for mn in repaired: + addr = self.nodes[0].getnewaddress() + self.nodes[0].sendtoaddress(addr, 0.1) + mn.update_service(self.nodes[0], submit=True, fundsAddr=addr) + if restart: + self.restart_masternodes(mns=repaired) + else: + for mn in repaired: + mn.get_node(self).setnetworkactive(True) + self.connect_nodes(mn.nodeIdx, 0) + + # syncing blocks only since node 0 has txes waiting to be mined + self.sync_blocks() + + # Make sure protxes are "safe" to mine even when InstantSend and ChainLocks are no longer functional + self.bump_mocktime(60 * 10 + 1) + self.generate(self.nodes[0], 1) + + # Isolate and re-connect all MNs (otherwise there might be open connections with no MNAUTH for MNs which were banned before) + for mn in self.mninfo: # type: MasternodeInfo + assert not check_banned(self.nodes[0], mn) + mn.get_node(self).setnetworkactive(False) + self.wait_until(lambda: mn.get_node(self).getconnectioncount() == 0) + # node0 has to notice the drop as well, or it rejects the fresh MNAUTH as a duplicate of the stale peer + self.wait_until(lambda: all(p.get("verified_proregtx_hash") != mn.proTxHash for p in self.nodes[0].getpeerinfo())) + mn.get_node(self).setnetworkactive(True) + force_finish_mnsync(mn.get_node(self)) + self.connect_nodes(mn.nodeIdx, 0) + + def reset_probe_timeouts(self): + # Make sure all masternodes will reconnect/re-probe + self.bump_mocktime(10 * 60 + 1) diff --git a/test/functional/test_runner.py b/test/functional/test_runner.py index a03b09f21936..ec907c7ee597 100755 --- a/test/functional/test_runner.py +++ b/test/functional/test_runner.py @@ -118,11 +118,13 @@ 'mempool_persist.py --descriptors', 'p2p_quorum_data.py', 'feature_llmq_simplepose.py', # NOTE: needs dash_hash to pass + 'feature_llmq_simplepose_capabilities.py', # NOTE: needs dash_hash to pass 'p2p_instantsend.py', 'feature_protx_version.py', 'feature_asset_locks.py', # NOTE: needs dash_hash to pass 'feature_llmq_signing.py', # NOTE: needs dash_hash to pass 'feature_llmq_is_retroactive.py', # NOTE: needs dash_hash to pass + 'feature_llmq_is_retroactive_session_timeout.py', # NOTE: needs dash_hash to pass 'feature_llmq_chainlocks.py', # NOTE: needs dash_hash to pass 'feature_llmq_chainlocks_automatic.py', # NOTE: needs dash_hash to pass 'feature_quorum_proof_chain.py', @@ -130,6 +132,7 @@ 'feature_masternode_shares.py', 'feature_llmq_signing.py --spork21', # NOTE: needs dash_hash to pass 'feature_llmq_simplepose.py --disable-spork23', # NOTE: needs dash_hash to pass + 'feature_llmq_simplepose_capabilities.py --disable-spork23', # NOTE: needs dash_hash to pass 'feature_llmq_rotation.py', # NOTE: needs dash_hash to pass 'feature_llmq_evo.py', # NOTE: needs dash_hash to pass 'feature_llmq_dkgerrors.py', # NOTE: needs dash_hash to pass @@ -148,6 +151,10 @@ 'feature_dip3_deterministicmns.py --descriptors', # NOTE: needs dash_hash to pass 'feature_notifications.py', # vv Tests less than 60s vv + 'feature_config_args.py', + 'mining_basic.py', + 'wallet_dust_protection.py --legacy-wallet', + 'wallet_dust_protection.py --descriptors', 'rpc_psbt.py --legacy-wallet', 'rpc_psbt.py --descriptors', 'wallet_fundrawtransaction.py --legacy-wallet', @@ -233,8 +240,6 @@ 'wallet_createwallet.py --legacy-wallet', 'wallet_createwallet.py --usecli', 'wallet_createwallet.py --descriptors', - 'wallet_dust_protection.py --legacy-wallet', - 'wallet_dust_protection.py --descriptors', 'wallet_reorgsrestore.py', 'wallet_watchonly.py --legacy-wallet', 'wallet_watchonly.py --usecli --legacy-wallet', @@ -341,7 +346,6 @@ 'wallet_mnemonicbits.py --legacy-wallet', 'wallet_mnemonicbits.py --descriptors', 'wallet_crosschain.py', - 'mining_basic.py', 'rpc_named_arguments.py', 'feature_startupnotify.py', 'feature_stats.py', @@ -432,7 +436,6 @@ 'feature_remove_pruned_files_on_startup.py', 'p2p_i2p_ports.py', 'p2p_i2p_sessions.py', - 'feature_config_args.py', 'feature_settings.py', 'rpc_getdescriptorinfo.py', 'rpc_getpeerinfo_deprecation.py',