From afd62f5b2a1c0ad80b3974b43a7a72e191168a15 Mon Sep 17 00:00:00 2001 From: Konstantin Akimov Date: Thu, 1 Oct 2026 23:15:20 +0700 Subject: [PATCH 01/10] test: wait for exactly the DKG messages the members report having sent wait_for_quorum_phase could only wait for a fixed number of received messages, so callers that don't know how many complaints or justifications a round produces passed None and moved the DKG on as soon as every member reported the phase. A member reports a phase as soon as its own messages are sent, not once the others have received them, so a late message was cut off by the next phase block and the round went down a different path than the test expected. The members' sent* flags of the same dkgstatus call give the exact number of messages every member has to receive, so derive the expected count from them when the caller passes None. --- .../test_framework/test_framework.py | 28 ++++++++++++++----- 1 file changed, 21 insertions(+), 7 deletions(-) diff --git a/test/functional/test_framework/test_framework.py b/test/functional/test_framework/test_framework.py index 202f3c42857b..9f5f2225405f 100755 --- a/test/functional/test_framework/test_framework.py +++ b/test/functional/test_framework/test_framework.py @@ -2139,8 +2139,18 @@ def ret(): self.wait_until(check_probes, timeout=timeout, sleep=1) def wait_for_quorum_phase(self, quorum_hash, phase, expected_member_count, check_received_messages, check_received_messages_count, mninfos, llmq_type_name="llmq_test", timeout=30, sleep=0.5): + # A member reports a phase only once its own messages of that phase are sent, so with + # check_received_messages_count=None the members' sent* flags give the exact number of + # messages every member has to receive before the DKG may move on + sent_flags = { + "receivedContributions": "sentContributions", + "receivedComplaints": "sentComplaint", + "receivedJustifications": "sentJustification", + "receivedPrematureCommitments": "sentPrematureCommitment", + } + def check_dkg_session(): - member_count = 0 + statuses = [] for mn in mninfos: s = mn.get_node(self).quorum("dkgstatus")["session"] for qs in s: @@ -2151,12 +2161,16 @@ def check_dkg_session(): continue if qstatus["phase"] != phase: return False - if check_received_messages is not None: - if qstatus[check_received_messages] < check_received_messages_count: - return False - member_count += 1 - break - return member_count >= expected_member_count + statuses.append(qstatus) + if len(statuses) < expected_member_count: + return False + if check_received_messages is not None: + expected = check_received_messages_count + if expected is None: + expected = sum(qstatus[sent_flags[check_received_messages]] for qstatus in statuses) + if any(qstatus[check_received_messages] < expected for qstatus in statuses): + return False + return True self.wait_until(check_dkg_session, timeout=timeout, sleep=sleep) From 89a1e04c5bba8f343cb896d01cb63c8e093ba68f Mon Sep 17 00:00:00 2001 From: Konstantin Akimov Date: Thu, 1 Oct 2026 23:15:20 +0700 Subject: [PATCH 02/10] test: wait for the DKG messages of every llmq type before the next phase Several llmq types run their DKG at the same base block, and the commitments of all of them apply PoSe penalties. mine_quorum and mine_cycle_quorum only waited for the messages of the requested type, so the next phase block could be mined while a message of another type was still in flight. That type then excluded the late member, which showed up as unexpected penalties and bans in the PoSe tests. Let wait_for_quorum_phase check every type's session at the base block when all members are online. The requested type keeps the caller's counts; the others get them from their sent* flags. Offline members break the relay graph of the other types, so their messages can't be expected then and the check stays limited to the requested type. --- .../test_framework/test_framework.py | 95 +++++++++++++------ 1 file changed, 65 insertions(+), 30 deletions(-) diff --git a/test/functional/test_framework/test_framework.py b/test/functional/test_framework/test_framework.py index 9f5f2225405f..043cceda230f 100755 --- a/test/functional/test_framework/test_framework.py +++ b/test/functional/test_framework/test_framework.py @@ -2138,7 +2138,7 @@ def ret(): self.wait_until(check_probes, timeout=timeout, sleep=1) - def wait_for_quorum_phase(self, quorum_hash, phase, expected_member_count, check_received_messages, check_received_messages_count, mninfos, llmq_type_name="llmq_test", timeout=30, sleep=0.5): + def wait_for_quorum_phase(self, quorum_hash, phase, expected_member_count, check_received_messages, check_received_messages_count, mninfos, llmq_type_name="llmq_test", timeout=30, sleep=0.5, all_types=False): # A member reports a phase only once its own messages of that phase are sent, so with # check_received_messages_count=None the members' sent* flags give the exact number of # messages every member has to receive before the DKG may move on @@ -2149,27 +2149,61 @@ def wait_for_quorum_phase(self, quorum_hash, phase, expected_member_count, check "receivedPrematureCommitments": "sentPrematureCommitment", } + # A masternode PoSe-banned after its selection into a quarter of a rotating quorum stays in + # that quorum's composition while the quarter is reused, but a banned masternode joins no DKG + online = {mn.proTxHash for mn in mninfos if not check_banned(self.nodes[0], mn)} + + def dkg_members(dkgstatus): + # The quorum connections of every type whose DKG runs at this base block come from the + # member selection itself, so unlike a session they are listed before a member's handler + # thread has noticed the block. Each masternode lists the members it has to be connected + # to, which together name every member of the type. + members = {} + for qc in dkgstatus["quorumConnections"]: + if qc.get("quorumHash") != quorum_hash: + continue + members.setdefault(qc["llmqType"], set()).update(c["proTxHash"] for c in qc["quorumConnections"]) + return members + def check_dkg_session(): - statuses = [] + # Every llmq type runs its DKG at this base block at once, and the commitments of the + # others apply PoSe penalties just the same, so with all_types all of them have to get + # their messages through before the next phase is mined; only the requested type has + # known counts. Offline members break the relay graph of the other types, so their + # messages can't be expected to arrive then. + statuses = {} + members = {} for mn in mninfos: - s = mn.get_node(self).quorum("dkgstatus")["session"] - for qs in s: - if qs["llmqType"] != llmq_type_name: + dkgstatus = mn.get_node(self).quorum("dkgstatus") + if all_types: + for llmq_type, type_members in dkg_members(dkgstatus).items(): + members.setdefault(llmq_type, set()).update(type_members) + for qs in dkgstatus["session"]: + if not all_types and qs["llmqType"] != llmq_type_name: continue qstatus = qs["status"] if qstatus["quorumHash"] != quorum_hash: continue if qstatus["phase"] != phase: return False - statuses.append(qstatus) - if len(statuses) < expected_member_count: + statuses.setdefault(qs["llmqType"], []).append(qstatus) + if len(statuses.get(llmq_type_name, [])) < expected_member_count: return False - if check_received_messages is not None: - expected = check_received_messages_count - if expected is None: - expected = sum(qstatus[sent_flags[check_received_messages]] for qstatus in statuses) - if any(qstatus[check_received_messages] < expected for qstatus in statuses): + for llmq_type, type_members in members.items(): + # A member reports the phase only once its handler thread has entered the round, and + # one that enters late has missed the messages sent so far, so every member has to be + # in before the DKG moves on. A DKG with fewer members than the type's minimum size + # never starts. + min_size = {"llmq_test_dip0024": self.llmq_size_dip0024, "llmq_test_v17": 2}.get(llmq_type, self.llmq_threshold) + if len(type_members) >= min_size and len(statuses.get(llmq_type, [])) < len(type_members & online): return False + if check_received_messages is not None: + for llmq_type, type_statuses in statuses.items(): + expected = check_received_messages_count if llmq_type == llmq_type_name else None + if expected is None: + expected = sum(qstatus[sent_flags[check_received_messages]] for qstatus in type_statuses) + if any(qstatus[check_received_messages] < expected for qstatus in type_statuses): + return False return True self.wait_until(check_dkg_session, timeout=timeout, sleep=sleep) @@ -2264,6 +2298,7 @@ def mine_quorum(self, llmq_type_name="llmq_test", llmq_type=100, expected_connec expected_justifications, expected_commitments)) nodes = [self.nodes[0]] + [mn.get_node(self) for mn in mninfos_online] + all_types = len(mninfos_online) == len(self.mninfo) # move forward to next DKG llmq_cycle_len = 24 @@ -2282,7 +2317,7 @@ def mine_quorum(self, llmq_type_name="llmq_test", llmq_type=100, expected_connec 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, llmq_type_name=llmq_type_name) + self.wait_for_quorum_phase(q, 1, expected_members, None, 0, mninfos_online, llmq_type_name=llmq_type_name, all_types=all_types) self.wait_for_quorum_connections(q, expected_connections, mninfos_online, wait_proc=lambda: self.bump_mocktime(1), llmq_type_name=llmq_type_name) if spork23_active: self.wait_for_masternode_probes(q, mninfos_online, wait_proc=lambda: self.bump_mocktime(1)) @@ -2290,27 +2325,27 @@ def mine_quorum(self, llmq_type_name="llmq_test", llmq_type=100, expected_connec self.move_blocks(nodes, 2) self.log.info("Waiting for phase 2 (contribute)") - self.wait_for_quorum_phase(q, 2, expected_members, "receivedContributions", expected_contributions, mninfos_online, llmq_type_name=llmq_type_name) + self.wait_for_quorum_phase(q, 2, expected_members, "receivedContributions", expected_contributions, mninfos_online, llmq_type_name=llmq_type_name, all_types=all_types) self.move_blocks(nodes, 2) self.log.info("Waiting for phase 3 (complain)") - self.wait_for_quorum_phase(q, 3, expected_members, "receivedComplaints", expected_complaints, mninfos_online, llmq_type_name=llmq_type_name) + self.wait_for_quorum_phase(q, 3, expected_members, "receivedComplaints", expected_complaints, mninfos_online, llmq_type_name=llmq_type_name, all_types=all_types) self.move_blocks(nodes, 2) self.log.info("Waiting for phase 4 (justify)") - self.wait_for_quorum_phase(q, 4, expected_members, "receivedJustifications", expected_justifications, mninfos_online, llmq_type_name=llmq_type_name) + self.wait_for_quorum_phase(q, 4, expected_members, "receivedJustifications", expected_justifications, mninfos_online, llmq_type_name=llmq_type_name, all_types=all_types) self.move_blocks(nodes, 2) self.log.info("Waiting for phase 5 (commit)") - self.wait_for_quorum_phase(q, 5, expected_members, "receivedPrematureCommitments", expected_commitments, mninfos_online, llmq_type_name=llmq_type_name) + self.wait_for_quorum_phase(q, 5, expected_members, "receivedPrematureCommitments", expected_commitments, mninfos_online, llmq_type_name=llmq_type_name, all_types=all_types) 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, llmq_type_name=llmq_type_name) + self.wait_for_quorum_phase(q, 6, expected_members, None, 0, mninfos_online, llmq_type_name=llmq_type_name, all_types=all_types) self.log.info("Waiting final commitment") self.wait_for_quorum_commitment(q, mninfos_online, llmq_type=llmq_type) @@ -2444,7 +2479,7 @@ def mine_cycle_quorum(self): self.log.info("Expected quorum_0 at:" + str(self.nodes[0].getblockcount())) self.log.info("Expected quorum_0 hash:" + str(q_0)) self.log.info("quorumIndex 0: Waiting for phase 1 (init)") - self.wait_for_quorum_phase(q_0, 1, expected_members, None, 0, mninfos_online, llmq_type_name) + self.wait_for_quorum_phase(q_0, 1, expected_members, None, 0, mninfos_online, llmq_type_name, all_types=True) self.log.info("quorumIndex 0: Waiting for quorum connections (init)") self.wait_for_quorum_connections(q_0, expected_connections, mninfos_online, llmq_type_name, wait_proc=lambda: self.bump_mocktime(1)) if spork23_active: @@ -2459,7 +2494,7 @@ def mine_cycle_quorum(self): self.log.info("Expected quorum_1 at:" + str(self.nodes[0].getblockcount())) self.log.info("Expected quorum_1 hash:" + str(q_1)) self.log.info("quorumIndex 1: Waiting for phase 1 (init)") - self.wait_for_quorum_phase(q_1, 1, expected_members, None, 0, mninfos_online, llmq_type_name) + self.wait_for_quorum_phase(q_1, 1, expected_members, None, 0, mninfos_online, llmq_type_name, all_types=True) self.log.info("quorumIndex 1: Waiting for quorum connections (init)") self.wait_for_quorum_connections(q_1, expected_connections, mninfos_online, llmq_type_name, wait_proc=lambda: self.bump_mocktime(1)) if spork23_active: @@ -2468,52 +2503,52 @@ def mine_cycle_quorum(self): self.move_blocks(nodes, 1) self.log.info("quorumIndex 0: Waiting for phase 2 (contribute)") - self.wait_for_quorum_phase(q_0, 2, expected_members, "receivedContributions", expected_contributions, mninfos_online, llmq_type_name) + self.wait_for_quorum_phase(q_0, 2, expected_members, "receivedContributions", expected_contributions, mninfos_online, llmq_type_name, all_types=True) self.move_blocks(nodes, 1) self.log.info("quorumIndex 1: Waiting for phase 2 (contribute)") - self.wait_for_quorum_phase(q_1, 2, expected_members, "receivedContributions", expected_contributions, mninfos_online, llmq_type_name) + self.wait_for_quorum_phase(q_1, 2, expected_members, "receivedContributions", expected_contributions, mninfos_online, llmq_type_name, all_types=True) self.move_blocks(nodes, 1) self.log.info("quorumIndex 0: Waiting for phase 3 (complain)") - self.wait_for_quorum_phase(q_0, 3, expected_members, "receivedComplaints", expected_complaints, mninfos_online, llmq_type_name) + self.wait_for_quorum_phase(q_0, 3, expected_members, "receivedComplaints", expected_complaints, mninfos_online, llmq_type_name, all_types=True) self.move_blocks(nodes, 1) self.log.info("quorumIndex 1: Waiting for phase 3 (complain)") - self.wait_for_quorum_phase(q_1, 3, expected_members, "receivedComplaints", expected_complaints, mninfos_online, llmq_type_name) + self.wait_for_quorum_phase(q_1, 3, expected_members, "receivedComplaints", expected_complaints, mninfos_online, llmq_type_name, all_types=True) self.move_blocks(nodes, 1) self.log.info("quorumIndex 0: Waiting for phase 4 (justify)") - self.wait_for_quorum_phase(q_0, 4, expected_members, "receivedJustifications", expected_justifications, mninfos_online, llmq_type_name) + self.wait_for_quorum_phase(q_0, 4, expected_members, "receivedJustifications", expected_justifications, mninfos_online, llmq_type_name, all_types=True) self.move_blocks(nodes, 1) self.log.info("quorumIndex 1: Waiting for phase 4 (justify)") - self.wait_for_quorum_phase(q_1, 4, expected_members, "receivedJustifications", expected_justifications, mninfos_online, llmq_type_name) + self.wait_for_quorum_phase(q_1, 4, expected_members, "receivedJustifications", expected_justifications, mninfos_online, llmq_type_name, all_types=True) self.move_blocks(nodes, 1) self.log.info("quorumIndex 0: Waiting for phase 5 (commit)") - self.wait_for_quorum_phase(q_0, 5, expected_members, "receivedPrematureCommitments", expected_commitments, mninfos_online, llmq_type_name) + self.wait_for_quorum_phase(q_0, 5, expected_members, "receivedPrematureCommitments", expected_commitments, mninfos_online, llmq_type_name, all_types=True) self.move_blocks(nodes, 1) self.log.info("quorumIndex 1: Waiting for phase 5 (commit)") - self.wait_for_quorum_phase(q_1, 5, expected_members, "receivedPrematureCommitments", expected_commitments, mninfos_online, llmq_type_name) + self.wait_for_quorum_phase(q_1, 5, expected_members, "receivedPrematureCommitments", expected_commitments, mninfos_online, llmq_type_name, all_types=True) self.move_blocks(nodes, 1) self.log.info("quorumIndex 0: Waiting for phase 6 (finalization)") - self.wait_for_quorum_phase(q_0, 6, expected_members, None, 0, mninfos_online, llmq_type_name) + self.wait_for_quorum_phase(q_0, 6, expected_members, None, 0, mninfos_online, llmq_type_name, all_types=True) self.move_blocks(nodes, 1) self.log.info("quorumIndex 1: Waiting for phase 6 (finalization)") - self.wait_for_quorum_phase(q_1, 6, expected_members, None, 0, mninfos_online, llmq_type_name) + self.wait_for_quorum_phase(q_1, 6, expected_members, None, 0, mninfos_online, llmq_type_name, all_types=True) self.log.info("Mining final commitments") self.bump_mocktime(1) self.nodes[0].getblocktemplate() # this calls CreateNewBlock From bb3969cf0cf4a863b8df9bd7a8d8b0e7bdc86de4 Mon Sep 17 00:00:00 2001 From: Konstantin Akimov Date: Thu, 1 Oct 2026 23:15:20 +0700 Subject: [PATCH 03/10] test: require the outbound quorum connections of every member wait_for_quorum_connections returned as soon as the first member with a session had enough connections, and it counted inbound ones. DKG messages are relayed over the outbound connections only, and only to the peers connected at the time, so a member that sent its contribution before its outbound connection to another member was up had that contribution lost for good. The round then went on without it and the test saw a complaint or an exclusion it didn't expect. Check every member, and don't accept a session while an outbound connection to an online member is still missing. --- .../test_framework/test_framework.py | 42 ++++++++++++------- 1 file changed, 28 insertions(+), 14 deletions(-) diff --git a/test/functional/test_framework/test_framework.py b/test/functional/test_framework/test_framework.py index 043cceda230f..df278c24e862 100755 --- a/test/functional/test_framework/test_framework.py +++ b/test/functional/test_framework/test_framework.py @@ -2061,24 +2061,29 @@ def check_sporks_same(): self.bump_mocktime(6, update_schedulers=False) self.wait_until(check_sporks_same, timeout=timeout) - def wait_for_quorum_connections(self, quorum_hash, expected_connections, mninfos, llmq_type_name="llmq_test", timeout = 60, wait_proc=None): + def wait_for_quorum_connections(self, quorum_hash, expected_connections, mninfos, llmq_type_name="llmq_test", timeout = 60, wait_proc=None, all_types=False): def check_quorum_connections(): def ret(): if wait_proc is not None: wait_proc() return False + # With all_types every type running its DKG at this base block has to have its connections + # up as well, or its contributions are lost and wait_for_quorum_phase never sees them; + # expected_connections only applies to the requested type + found = False + online = {mn.proTxHash for mn in mninfos} for mn in mninfos: s = mn.get_node(self).quorum("dkgstatus") for qs in s["session"]: - if qs["llmqType"] != llmq_type_name: + if not all_types and qs["llmqType"] != llmq_type_name: continue if qs["status"]["quorumHash"] != quorum_hash: continue for qc in s["quorumConnections"]: if "quorumConnections" not in qc: continue - if qc["llmqType"] != llmq_type_name: + if qc["llmqType"] != qs["llmqType"]: continue if qc["quorumHash"] != quorum_hash: continue @@ -2088,15 +2093,24 @@ def ret(): for c in qc["quorumConnections"]: if c["connected"]: cnt += 1 - if cnt < expected_connections: - return ret() - return True - # a session with no matching connections - not ok - return ret() + elif c["outbound"] and c["proTxHash"] in online: + # DKG messages are only relayed over the outbound connections, and only to the + # peers connected at the time, so a member must have all of them before it + # sends anything + return ret() + if qs["llmqType"] == llmq_type_name: + if cnt < expected_connections: + return ret() + found = True + break + else: + # a session with no matching connections - not ok + return ret() # a node with no sessions - ok - pass - # no sessions at all - not ok - return ret() + if not found: + # no sessions at all - not ok + return ret() + return True self.wait_until(check_quorum_connections, timeout=timeout, sleep=1) @@ -2318,7 +2332,7 @@ def mine_quorum(self, llmq_type_name="llmq_test", llmq_type=100, expected_connec 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, llmq_type_name=llmq_type_name, all_types=all_types) - self.wait_for_quorum_connections(q, expected_connections, mninfos_online, wait_proc=lambda: self.bump_mocktime(1), llmq_type_name=llmq_type_name) + self.wait_for_quorum_connections(q, expected_connections, mninfos_online, wait_proc=lambda: self.bump_mocktime(1), llmq_type_name=llmq_type_name, all_types=all_types) if spork23_active: self.wait_for_masternode_probes(q, mninfos_online, wait_proc=lambda: self.bump_mocktime(1)) @@ -2481,7 +2495,7 @@ def mine_cycle_quorum(self): self.log.info("quorumIndex 0: Waiting for phase 1 (init)") self.wait_for_quorum_phase(q_0, 1, expected_members, None, 0, mninfos_online, llmq_type_name, all_types=True) self.log.info("quorumIndex 0: Waiting for quorum connections (init)") - self.wait_for_quorum_connections(q_0, expected_connections, mninfos_online, llmq_type_name, wait_proc=lambda: self.bump_mocktime(1)) + self.wait_for_quorum_connections(q_0, expected_connections, mninfos_online, llmq_type_name, wait_proc=lambda: self.bump_mocktime(1), all_types=True) if spork23_active: self.wait_for_masternode_probes(q_0, mninfos_online, wait_proc=lambda: self.bump_mocktime(1), llmq_type_name=llmq_type_name) @@ -2496,7 +2510,7 @@ def mine_cycle_quorum(self): self.log.info("quorumIndex 1: Waiting for phase 1 (init)") self.wait_for_quorum_phase(q_1, 1, expected_members, None, 0, mninfos_online, llmq_type_name, all_types=True) self.log.info("quorumIndex 1: Waiting for quorum connections (init)") - self.wait_for_quorum_connections(q_1, expected_connections, mninfos_online, llmq_type_name, wait_proc=lambda: self.bump_mocktime(1)) + self.wait_for_quorum_connections(q_1, expected_connections, mninfos_online, llmq_type_name, wait_proc=lambda: self.bump_mocktime(1), all_types=True) if spork23_active: self.wait_for_masternode_probes(q_1, mninfos_online, wait_proc=lambda: self.bump_mocktime(1), llmq_type_name=llmq_type_name) From 73a69fa5b9b406375e0743bc1dea30f867b67bac Mon Sep 17 00:00:00 2001 From: Konstantin Akimov Date: Thu, 1 Oct 2026 23:15:20 +0700 Subject: [PATCH 04/10] test: wait for both final commitments in mine_cycle_quorum mine_quorum waits until the members have the final commitment and the miner has it in its mineable set before it mines the block; the rotation variant mined right after phase 6 was reported. The members report phase 6 as soon as their own commitment is sent, so the block could be mined before the miner had both commitments, and the new quorums then never appeared in the list. Apply the same two waits to both quorum indexes. --- test/functional/test_framework/test_framework.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/test/functional/test_framework/test_framework.py b/test/functional/test_framework/test_framework.py index df278c24e862..5f09bfc2605b 100755 --- a/test/functional/test_framework/test_framework.py +++ b/test/functional/test_framework/test_framework.py @@ -2563,6 +2563,15 @@ def mine_cycle_quorum(self): self.log.info("quorumIndex 1: Waiting for phase 6 (finalization)") self.wait_for_quorum_phase(q_1, 6, expected_members, None, 0, mninfos_online, llmq_type_name, all_types=True) + + self.log.info("Waiting final commitments") + self.wait_for_quorum_commitment(q_0, mninfos_online, llmq_type=llmq_type) + self.wait_for_quorum_commitment(q_1, mninfos_online, llmq_type=llmq_type) + + self.log.info("Waiting final commitments on mining node") + self.wait_for_quorum_commitments_on_miner(q_0, mninfos_online) + self.wait_for_quorum_commitments_on_miner(q_1, mninfos_online) + self.log.info("Mining final commitments") self.bump_mocktime(1) self.nodes[0].getblocktemplate() # this calls CreateNewBlock From 375876338d20f22fb4f78b323946b1c1d741f197 Mon Sep 17 00:00:00 2001 From: Konstantin Akimov Date: Thu, 1 Oct 2026 23:15:20 +0700 Subject: [PATCH 05/10] test: make the PoSe penalty rounds of feature_llmq_simplepose deterministic mine_quorum_less_checks moved the DKG from phase to phase as soon as the good members reported the phase, without waiting for the quorum connections, for the round's probe of the deaf masternode, or for the complaints and justifications to arrive. Which path a round took then depended on timing: a probe that was attempted only after the phase block, or a complaint cut off by the next phase, left the deaf member unpenalised and the test ran out of rounds. Six rounds were also one too few: a member is marked bad from the sixth round on, and each exclusion costs 66% of the ban threshold, so the ban is certain in the seventh. Wait for the outbound connections, for this round's probe attempt, and for exactly the messages the members report having sent; the deaf masternode is a regular participant until its probes fail, so it is counted as a member. Wait for the miner to have the commitment before mining it, and run seven rounds. --- test/functional/feature_llmq_simplepose.py | 55 ++++++++++++++++------ 1 file changed, 41 insertions(+), 14 deletions(-) diff --git a/test/functional/feature_llmq_simplepose.py b/test/functional/feature_llmq_simplepose.py index 4ad57c34c7e3..b7ec8ac7d370 100755 --- a/test/functional/feature_llmq_simplepose.py +++ b/test/functional/feature_llmq_simplepose.py @@ -241,9 +241,10 @@ def test_no_banning(self, invalidate_proc, expected_connections=None): 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_good_nodes, mninfos_online): - # Unlike in mine_quorum we skip most of the checks and only care about - # nodes moving forward from phase to phase correctly and the fact that the quorum is actually mined. + 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] @@ -256,31 +257,37 @@ def mine_quorum_less_checks(self, expected_good_nodes, mninfos_online): 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_good_nodes, None, 0, mninfos_online) + self.wait_for_quorum_phase(q, 1, expected_members, None, 0, mninfos_online) + self.wait_for_quorum_connections(q, expected_members - 1, mninfos_online, wait_proc=lambda: self.bump_mocktime(1)) + 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_good_nodes, "receivedContributions", expected_good_nodes, mninfos_online) + self.wait_for_quorum_phase(q, 2, expected_members, "receivedContributions", expected_members, mninfos_online) self.move_blocks(nodes, 2) self.log.info("Waiting for phase 3 (complain)") - self.wait_for_quorum_phase(q, 3, expected_good_nodes, None, 0, mninfos_online) + self.wait_for_quorum_phase(q, 3, expected_members, "receivedComplaints", None, mninfos_online) self.move_blocks(nodes, 2) self.log.info("Waiting for phase 4 (justify)") - self.wait_for_quorum_phase(q, 4, expected_good_nodes, None, 0, mninfos_online) + self.wait_for_quorum_phase(q, 4, expected_members, "receivedJustifications", None, mninfos_online) self.move_blocks(nodes, 2) self.log.info("Waiting for phase 5 (commit)") - self.wait_for_quorum_phase(q, 5, expected_good_nodes, "receivedPrematureCommitments", expected_good_nodes, mninfos_online) + self.wait_for_quorum_phase(q, 5, expected_members, "receivedPrematureCommitments", expected_members, mninfos_online) self.move_blocks(nodes, 2) self.log.info("Waiting for phase 6 (mining)") - self.wait_for_quorum_phase(q, 6, expected_good_nodes, None, 0, mninfos_online) + self.wait_for_quorum_phase(q, 6, expected_members, None, 0, 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 @@ -300,6 +307,25 @@ def mine_quorum_less_checks(self, expected_good_nodes, mninfos_online): 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() @@ -331,13 +357,14 @@ def test_banning(self, invalidate_proc, expected_connections=None): 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: - # It's ok to miss probes/quorum connections up to 5 times. - # 6th time is when it should be banned for sure. + # 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(6): - self.log.info(f"Accumulating PoSe penalty {j + 1}/6") + 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 - 1, mninfos_online) + self.mine_quorum_less_checks(expected_contributors, mninfos_online) if check_banned(self.nodes[0], mn): break From a6635ed27228e97b560a8a9c3810c8e821928206 Mon Sep 17 00:00:00 2001 From: Konstantin Akimov Date: Thu, 1 Oct 2026 23:15:20 +0700 Subject: [PATCH 06/10] test: let node0 drop the old masternode peer before it reconnects The repair step of feature_llmq_simplepose turns a masternode's networking off and on and reconnects it to node0 as soon as the masternode itself has no connections. node0 may still hold the old connection then, and a second MNAUTH for the same masternode is rejected as a duplicate, so the new connection never authenticates and the masternode is treated as still deaf. Wait until node0 has no peer verified as that masternode before reconnecting. --- test/functional/feature_llmq_simplepose.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/test/functional/feature_llmq_simplepose.py b/test/functional/feature_llmq_simplepose.py index b7ec8ac7d370..21635bcabfb8 100755 --- a/test/functional/feature_llmq_simplepose.py +++ b/test/functional/feature_llmq_simplepose.py @@ -400,6 +400,8 @@ def repair_masternodes(self, restart): 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) From 28fbaf367aac8f28f141e8faf59849c827a67911 Mon Sep 17 00:00:00 2001 From: Konstantin Akimov Date: Thu, 1 Oct 2026 23:15:20 +0700 Subject: [PATCH 07/10] test: poll quorum connections and probes at the default interval wait_for_quorum_connections and wait_for_masternode_probes polled once a second, which is most of the time a DKG round takes on regtest now. The second was there because the callers' wait_proc bumps mocktime by one second per poll, and polling faster made mocktime outrun the wall clock, after which the masternodes considered their peers timed out. Poll at the default interval and run wait_proc at most once per real second. --- .../test_framework/test_framework.py | 25 +++++++++++++++++-- 1 file changed, 23 insertions(+), 2 deletions(-) diff --git a/test/functional/test_framework/test_framework.py b/test/functional/test_framework/test_framework.py index 5f09bfc2605b..5d06d107b6fe 100755 --- a/test/functional/test_framework/test_framework.py +++ b/test/functional/test_framework/test_framework.py @@ -1492,6 +1492,19 @@ def update_service(self, node: TestNode, submit: bool, addrs_core_p2p: Union[str return ret +def throttled(proc, interval=1): + # proc has side effects like bumping mocktime, so run it at most once per interval of real time + # no matter how fast the caller polls + last = 0 + + def run(): + nonlocal last + if time.time() - last >= interval: + proc() + last = time.time() + return run + + class DashTestFramework(BitcoinTestFramework): def set_test_params(self): """Tests must this method to change default values for number of nodes, topology, etc""" @@ -2062,6 +2075,10 @@ def check_sporks_same(): self.wait_until(check_sporks_same, timeout=timeout) def wait_for_quorum_connections(self, quorum_hash, expected_connections, mninfos, llmq_type_name="llmq_test", timeout = 60, wait_proc=None, all_types=False): + if wait_proc is not None: + # wait_proc bumps mocktime by a second, keep it from outrunning the wall clock + wait_proc = throttled(wait_proc) + def check_quorum_connections(): def ret(): if wait_proc is not None: @@ -2112,9 +2129,13 @@ def ret(): return ret() return True - self.wait_until(check_quorum_connections, timeout=timeout, sleep=1) + self.wait_until(check_quorum_connections, timeout=timeout) def wait_for_masternode_probes(self, quorum_hash, mninfos, timeout = 30, wait_proc=None, llmq_type_name="llmq_test"): + if wait_proc is not None: + # wait_proc bumps mocktime by a second, keep it from outrunning the wall clock + wait_proc = throttled(wait_proc) + def check_probes(): def ret(): if wait_proc is not None: @@ -2150,7 +2171,7 @@ def ret(): return ret() return True - self.wait_until(check_probes, timeout=timeout, sleep=1) + self.wait_until(check_probes, timeout=timeout) def wait_for_quorum_phase(self, quorum_hash, phase, expected_member_count, check_received_messages, check_received_messages_count, mninfos, llmq_type_name="llmq_test", timeout=30, sleep=0.5, all_types=False): # A member reports a phase only once its own messages of that phase are sent, so with From f2c5fc84831e13ce30f6b96b6918da830e08fe5b Mon Sep 17 00:00:00 2001 From: Konstantin Akimov Date: Thu, 1 Oct 2026 23:15:20 +0700 Subject: [PATCH 08/10] test: poll DKG phases, recovered sigs and mnauth at the default interval wait_for_quorum_phase, wait_for_recovered_sig and wait_for_mnauth polled every 0.5 or 1 second, which on regtest is longer than the events they wait for take. Now that the waits only return once the messages they are about have arrived there is nothing a slower poll protects. Use the default 0.05s interval. --- .../test_framework/test_framework.py | 28 ++++++++++++------- 1 file changed, 18 insertions(+), 10 deletions(-) diff --git a/test/functional/test_framework/test_framework.py b/test/functional/test_framework/test_framework.py index 5d06d107b6fe..75125f6a9954 100755 --- a/test/functional/test_framework/test_framework.py +++ b/test/functional/test_framework/test_framework.py @@ -2173,7 +2173,7 @@ def ret(): self.wait_until(check_probes, timeout=timeout) - def wait_for_quorum_phase(self, quorum_hash, phase, expected_member_count, check_received_messages, check_received_messages_count, mninfos, llmq_type_name="llmq_test", timeout=30, sleep=0.5, all_types=False): + def wait_for_quorum_phase(self, quorum_hash, phase, expected_member_count, check_received_messages, check_received_messages_count, mninfos, llmq_type_name="llmq_test", timeout=30, all_types=False): # A member reports a phase only once its own messages of that phase are sent, so with # check_received_messages_count=None the members' sent* flags give the exact number of # messages every member has to receive before the DKG may move on @@ -2241,7 +2241,7 @@ def check_dkg_session(): return False return True - self.wait_until(check_dkg_session, timeout=timeout, sleep=sleep) + self.wait_until(check_dkg_session, timeout=timeout) def node_quorum_commitment_types(self, node, quorum_hash): s = node.quorum("dkgstatus") @@ -2627,13 +2627,15 @@ def mine_cycle_quorum(self): return (quorum_info_0, quorum_info_1) def wait_for_recovered_sig(self, rec_sig_id, rec_sig_msg_hash, llmq_type=100, timeout=10): + bump_mocktime = throttled(lambda: self.bump_mocktime(1)) + def check_recovered_sig(): - self.bump_mocktime(1) + bump_mocktime() for mn in self.mninfo: # type: MasternodeInfo if not mn.get_node(self).quorum("hasrecsig", llmq_type, rec_sig_id, rec_sig_msg_hash): return False return True - self.wait_until(check_recovered_sig, timeout=timeout, sleep=1) + self.wait_until(check_recovered_sig, timeout=timeout) def get_recovered_sig(self, rec_sig_id, rec_sig_msg_hash, llmq_type=100, use_platformsign=False): # Note: recsigs aren't relayed to regular nodes by default, @@ -2679,14 +2681,20 @@ def test_mn_quorum_data(self, test_mn: MasternodeInfo, quorum_type_in, quorum_ha def wait_for_quorum_data(self, mns, quorum_type_in, quorum_hash_in, test_secret=True, expect_secret=True, recover=False, timeout=60): + def expire_request(): + if self.mocktime % 2: + self.bump_mocktime(self.quorum_data_request_expiration_timeout + 1) + self.generate(self.nodes[0], 1, sync_fun=lambda: self.sync_blocks()) + else: + self.bump_mocktime(self.quorum_data_thread_request_timeout_seconds + 1) + # the recovery thread retries once per real second, so give it the time to answer a request + # before the next one is expired + expire_request = throttled(expire_request, 0.5) + def test_mns(): valid = 0 if recover: - if self.mocktime % 2: - self.bump_mocktime(self.quorum_data_request_expiration_timeout + 1) - self.generate(self.nodes[0], 1, sync_fun=lambda: self.sync_blocks()) - else: - self.bump_mocktime(self.quorum_data_thread_request_timeout_seconds + 1) + expire_request() for test_mn in mns: valid += self.test_mn_quorum_data(test_mn, quorum_type_in, quorum_hash_in, test_secret, expect_secret) @@ -2694,7 +2702,7 @@ def test_mns(): (valid, len(mns), quorum_type_in, quorum_hash_in)) return valid == len(mns) - self.wait_until(test_mns, timeout=timeout, sleep=0.5) + self.wait_until(test_mns, timeout=timeout) def wait_for_mnauth(self, node, count, timeout=10): def test(): From b27e9327b522f497ec70ec82850e141364790648 Mon Sep 17 00:00:00 2001 From: Konstantin Akimov Date: Thu, 1 Oct 2026 23:15:21 +0700 Subject: [PATCH 09/10] test: drop the sleep in reset_probe_timeouts of feature_llmq_simplepose reset_probe_timeouts slept two seconds "to let mn sync tick to happen" before every DKG round. The rounds now wait for this round's probe attempt explicitly, so the sleep protects nothing and costs two seconds per round. --- test/functional/feature_llmq_simplepose.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/test/functional/feature_llmq_simplepose.py b/test/functional/feature_llmq_simplepose.py index 21635bcabfb8..ae41dbcab906 100755 --- a/test/functional/feature_llmq_simplepose.py +++ b/test/functional/feature_llmq_simplepose.py @@ -10,8 +10,6 @@ ''' -import time - 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 @@ -409,8 +407,6 @@ def repair_masternodes(self, restart): def reset_probe_timeouts(self): # Make sure all masternodes will reconnect/re-probe self.bump_mocktime(10 * 60 + 1) - # Sleep a couple of seconds to let mn sync tick to happen - time.sleep(2) if __name__ == '__main__': From fb42fc6270b8ea4f85a49b707a0d728cc5832f02 Mon Sep 17 00:00:00 2001 From: Konstantin Akimov Date: Fri, 16 Oct 2026 01:33:50 +0700 Subject: [PATCH 10/10] test: actually wait for the DKG messages of every llmq type before the next phase for all-types The two waits before the disconnect in mine_quorum_no_service cover all five masternodes with all_types=True, and every phase and connection wait of mine_quorum_less_checks gets all_types=True. --- test/functional/feature_llmq_simplepose.py | 26 +++++++++++----------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/test/functional/feature_llmq_simplepose.py b/test/functional/feature_llmq_simplepose.py index ae41dbcab906..6fa9e8f3f1cb 100755 --- a/test/functional/feature_llmq_simplepose.py +++ b/test/functional/feature_llmq_simplepose.py @@ -155,9 +155,9 @@ def test_no_service(self, relay, services, reason, 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 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. + # 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 @@ -170,15 +170,15 @@ def mine_quorum_no_service(self, mn, others, relay, services, expected_complaint 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(others), None, 0, others) - self.wait_for_quorum_connections(q, self.llmq_size - 1, others, wait_proc=lambda: self.bump_mocktime(1)) + 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(others), "receivedContributions", self.llmq_size, others) + self.wait_for_quorum_phase(q, 2, len(self.mninfo), "receivedContributions", self.llmq_size, self.mninfo, all_types=True) self.log.info("Replacing the connections to the masternode with stand-ins") mn.get_node(self).setnetworkactive(False) @@ -255,30 +255,30 @@ def mine_quorum_less_checks(self, expected_members, mninfos_online): 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) - self.wait_for_quorum_connections(q, expected_members - 1, mninfos_online, wait_proc=lambda: self.bump_mocktime(1)) + 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) + self.wait_for_quorum_phase(q, 2, expected_members, "receivedContributions", expected_members, mninfos_online, all_types=True) 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) + self.wait_for_quorum_phase(q, 3, expected_members, "receivedComplaints", None, mninfos_online, all_types=True) 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) + self.wait_for_quorum_phase(q, 4, expected_members, "receivedJustifications", None, mninfos_online, all_types=True) 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) + self.wait_for_quorum_phase(q, 5, expected_members, "receivedPrematureCommitments", expected_members, mninfos_online, all_types=True) 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) + self.wait_for_quorum_phase(q, 6, expected_members, None, 0, mninfos_online, all_types=True) self.log.info("Waiting final commitment") self.wait_for_quorum_commitment(q, mninfos_online)