From c55c54f3a5c112450e37771e6a33dfe6c471fc69 Mon Sep 17 00:00:00 2001 From: PastaClaw Date: Sat, 11 Jul 2026 21:21:23 -0500 Subject: [PATCH 1/2] test: reduce functional test node counts via single-member LLMQs Add the proven single-member quorum flow to DashTestFramework and use it to shrink three functional tests to the minimum masternode count they need: - rpc_quorum.py: 4 nodes / 3 masternodes -> 2 / 1 - rpc_verifychainlock.py: 5 / 3 -> 3 / 1 - feature_governance_objects.py: 2 / 1 -> 1 / 0 The shared mining helper preserves feature_llmq_singlenode.py's established control flow: align to the next 24-block boundary, wait for DKG processing, mine 30 blocks, and assert that the quorum list changed. It additionally returns the new quorum hash for rpc_quorum.py. Framework-prepared masternodes can miss the first DKG cycle because its work block predates their confirmedHash. Add a readiness helper that advances by whole cycles until the next work block contains every expected confirmed masternode, avoiding block-by-block generation. Co-Authored-By: Claude Opus 4.8 (1M context) --- test/functional/feature_governance_objects.py | 2 +- test/functional/rpc_quorum.py | 8 ++- test/functional/rpc_verifychainlock.py | 11 ++-- .../test_framework/test_framework.py | 60 +++++++++++++++++++ 4 files changed, 74 insertions(+), 7 deletions(-) diff --git a/test/functional/feature_governance_objects.py b/test/functional/feature_governance_objects.py index 845099581221..c37524541028 100755 --- a/test/functional/feature_governance_objects.py +++ b/test/functional/feature_governance_objects.py @@ -26,7 +26,7 @@ def add_options(self, parser): self.add_wallet_options(parser) def set_test_params(self): - self.set_dash_test_params(2, 1) + self.set_dash_test_params(1, 0) def prepare_object(self, object_type, parent_hash, creation_time, revision, name, amount): payment_address = self.nodes[0].getnewaddress() diff --git a/test/functional/rpc_quorum.py b/test/functional/rpc_quorum.py index 2b09b04839e8..6be534da2d95 100755 --- a/test/functional/rpc_quorum.py +++ b/test/functional/rpc_quorum.py @@ -19,12 +19,16 @@ def add_options(self, parser): self.add_wallet_options(parser) def set_test_params(self): - self.set_dash_test_params(4, 3) + # A single-member quorum exercises the same per-member `quorum info` + # output the test asserts, so one masternode is sufficient. + self.set_dash_test_params(2, 1) + self.set_dash_llmq_test_params(1, 1) def run_test(self): self.nodes[0].sporkupdate("SPORK_17_QUORUM_DKG_ENABLED", 0) self.wait_for_sporks_same() - quorum_hash = self.mine_quorum() + self.mine_until_mns_confirmed_for_next_dkg() + quorum_hash = self.mine_quorum_single_member() quorum_info = self.nodes[0].quorum("info", 100, quorum_hash) for idx in range(0, self.mn_count): diff --git a/test/functional/rpc_verifychainlock.py b/test/functional/rpc_verifychainlock.py index a3418007f10c..32c13db98e00 100755 --- a/test/functional/rpc_verifychainlock.py +++ b/test/functional/rpc_verifychainlock.py @@ -21,9 +21,11 @@ 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, 3, [["-whitelist=127.0.0.1"], [], [], [], []]) - self.set_dash_llmq_test_params(3, 2) + # -whitelist is needed to avoid the trickling logic on node0. + # A single-member quorum still produces real ChainLocks; node1 stays a + # non-masternode so it can be isolated to test divergent ChainLock state. + self.set_dash_test_params(3, 1, [["-whitelist=127.0.0.1"], [], []]) + self.set_dash_llmq_test_params(1, 1) def cl_helper(self, height, chainlock, mempool): return {'height': height, 'chainlock': chainlock, 'mempool': mempool} @@ -33,7 +35,8 @@ def run_test(self): node1 = self.nodes[1] self.nodes[0].sporkupdate("SPORK_17_QUORUM_DKG_ENABLED", 0) self.wait_for_sporks_same() - self.mine_quorum() + self.mine_until_mns_confirmed_for_next_dkg() + self.mine_quorum_single_member() self.wait_for_chainlocked_block(node0, self.generate(node0, 1, sync_fun=self.no_op)[0]) chainlock = node0.getbestchainlock() block_hash = chainlock["blockhash"] diff --git a/test/functional/test_framework/test_framework.py b/test/functional/test_framework/test_framework.py index fc5030a62bc4..123a7196a9e0 100755 --- a/test/functional/test_framework/test_framework.py +++ b/test/functional/test_framework/test_framework.py @@ -2229,6 +2229,66 @@ def mine_quorum(self, llmq_type_name="llmq_test", llmq_type=100, expected_connec return new_quorum + def mine_until_mns_confirmed_for_next_dkg(self): + """Advance the chain until the next non-rotating DKG cycle can select members. + + Members are picked from the masternode list of the work block, which sits + WORK_DIFF_DEPTH (8) blocks below the cycle's base block, and only masternodes + that already carry a confirmedHash there are eligible. Masternodes registered + shortly before a cycle are still unconfirmed at its work block, so that cycle + would select nobody and produce no quorum at all. Skip such cycles by mining + to the next base block, one whole cycle at a time. + + A work block above the tip is checked at the tip instead: masternodes are + registered before this runs and a confirmedHash is never unset again, so + whatever is confirmed at the tip stays confirmed at any later work block. + """ + node = self.nodes[0] + dkg_interval = 24 + work_diff_depth = 8 + expected_mns = {mn.proTxHash for mn in self.mninfo} + # Confirmation takes nMasternodeMinimumConfirmations (1 on regtest) blocks after + # registration, so at most two cycles of headroom are needed: registration can + # fall just below a base block, leaving the cycle after it still unconfirmed. + for attempt in range(3): + height = node.getblockcount() + next_base_height = height + dkg_interval - (height % dkg_interval) + work_height = min(height, next_base_height - work_diff_depth) + # A masternode missing from the work block's list is as unselectable as an + # unconfirmed one, so require every masternode to be present and confirmed. + confirmed_mns = {mn['proRegTxHash'] for mn in node.protx('diff', 1, work_height)['mnList'] + if int(mn['confirmedHash'], 16) != 0} + if expected_mns.issubset(confirmed_mns): + return + assert attempt < 2, f"masternodes are still unconfirmed at work block {work_height}" + self.log.info(f"Skipping DKG cycle with base height={next_base_height}: not all " + f"masternodes are confirmed at its work block {work_height}") + self.bump_mocktime(1) + self.generate(node, next_base_height - height) + + def mine_quorum_single_member(self): + """Mine a single-member (llmq_size == 1) quorum and return its hash. + + mine_quorum can't be reused for these: a one-member DKG has no + inter-member connections or probes, so its phase and connection waits + never complete. + """ + node = self.nodes[0] + quorums = node.quorum('list')['llmq_test'] + + skip_count = 24 - (node.getblockcount() % 24) + if skip_count != 0: + self.bump_mocktime(1) + self.generate(node, skip_count) + time.sleep(1) + self.generate(node, 30) + new_quorums_list = node.quorum('list')['llmq_test'] + + self.log.info(f"Test Quorums at height={node.getblockcount()} : {new_quorums_list}") + assert new_quorums_list != quorums + + return (set(new_quorums_list) - set(quorums)).pop() + def mine_cycle_quorum(self): spork21_active = self.nodes[0].spork('show')['SPORK_21_QUORUM_ALL_CONNECTED'] <= 1 spork23_active = self.nodes[0].spork('show')['SPORK_23_QUORUM_POSE'] <= 1 From 944e487c43165dc0950e299c42784a4db26290cd Mon Sep 17 00:00:00 2001 From: PastaClaw Date: Sat, 11 Jul 2026 21:21:23 -0500 Subject: [PATCH 2/2] test: reuse single-member quorum helper in feature_llmq_singlenode Replace feature_llmq_singlenode.py's local mine_single_node_quorum method with the shared framework helper and drop the now-unused time import. Co-Authored-By: Claude Opus 4.8 (1M context) --- test/functional/feature_llmq_singlenode.py | 25 ++++------------------ 1 file changed, 4 insertions(+), 21 deletions(-) diff --git a/test/functional/feature_llmq_singlenode.py b/test/functional/feature_llmq_singlenode.py index 8e0ba2b1209b..4e7040b85994 100755 --- a/test/functional/feature_llmq_singlenode.py +++ b/test/functional/feature_llmq_singlenode.py @@ -11,8 +11,6 @@ ''' -import time - from test_framework.authproxy import JSONRPCException from test_framework.test_framework import ( DashTestFramework, @@ -40,21 +38,6 @@ def set_test_params(self): evo_count=2) self.set_dash_llmq_test_params(1, 1) - def mine_single_node_quorum(self): - node = self.nodes[0] - quorums = node.quorum('list')['llmq_test'] - - skip_count = 24 - (self.nodes[0].getblockcount() % 24) - if skip_count != 0: - self.bump_mocktime(1) - self.generate(self.nodes[0], skip_count) - time.sleep(1) - self.generate(self.nodes[0], 30) - new_quorums_list = node.quorum('list')['llmq_test'] - - self.log.info(f"Test Quorums at height={node.getblockcount()} : {new_quorums_list}") - assert new_quorums_list != quorums - def check_sigs(self, hasrecsigs, isconflicting1, isconflicting2): has_sig = False conflicting_1 = False @@ -97,7 +80,7 @@ def run_test(self): self.dynamically_add_masternode(evo=True) self.connect_nodes(1, 2) - self.mine_single_node_quorum() + self.mine_quorum_single_member() self.log_connections() assert_greater_than(len(self.nodes[0].quorum('list')['llmq_test']), 0) @@ -158,13 +141,13 @@ def run_test(self): assert_raises_rpc_error(-8, "quorum not found", node.quorum, "verify", q_type, id, msgHash, recsig["sig"], hash_bad) self.log.info("Mine one more quorum, so that we have 2 active ones, nothing should change") - self.mine_single_node_quorum() + self.mine_quorum_single_member() self.assert_sigs_nochange(True, False, True, 3) self.log.info("Mine 2 more quorums, so that the one used for the the recovered sig should become inactive, nothing should change") - self.mine_single_node_quorum() - self.mine_single_node_quorum() + self.mine_quorum_single_member() + self.mine_quorum_single_member() self.assert_sigs_nochange(True, False, True, 3) self.log_connections()