From 1cea97fda8264c74dfd2614aaaca5f62ac52c8c4 Mon Sep 17 00:00:00 2001 From: pasta Date: Sat, 1 Aug 2026 20:18:23 -0500 Subject: [PATCH 1/2] test: wait for every quorum commitment on the mining node mine_quorum() waited for the mining node to hold the final commitment only for the llmq type it was asked to mine. The block it then generates carries a commitment for every llmq type whose mining window is open, and in regtest all test types share dkgInterval=24, so they all finalize on the same block. A type whose real commitment has not reached the mining node in time is mined as a null commitment (src/llmq/blockprocessor.cpp:842-846). Null commitments are accepted without being recorded as mined (src/llmq/blockprocessor.cpp:322-331), so that quorum is silently skipped for the whole cycle. Seen at block 178 of a failing feature_asset_locks.py run, where llmq_test got signers=0, validMembers=0, quorumPublicKey=0...0 while the driven llmq_test_instantsend commitment was mined correctly. Wait for the mining node to hold every commitment the masternodes actually produced. Types whose DKG legitimately produced nothing are not awaited, so this cannot hold up a test that expects a type not to form. --- .../test_framework/test_framework.py | 37 +++++++++++-------- 1 file changed, 22 insertions(+), 15 deletions(-) diff --git a/test/functional/test_framework/test_framework.py b/test/functional/test_framework/test_framework.py index 324d1498e579..eb00105ca1b8 100755 --- a/test/functional/test_framework/test_framework.py +++ b/test/functional/test_framework/test_framework.py @@ -2151,20 +2151,13 @@ def check_dkg_session(): self.wait_until(check_dkg_session, timeout=timeout, sleep=sleep) - def node_has_quorum_commitment(self, node, quorum_hash, llmq_type): + def node_quorum_commitment_types(self, node, quorum_hash): s = node.quorum("dkgstatus") - if "minableCommitments" not in s: - return False - commits = s["minableCommitments"] - for c in commits: - if c["llmqType"] != llmq_type: - continue - if c["quorumHash"] != quorum_hash: - continue - if c["quorumPublicKey"] == '0' * 96: - continue - return True - return False + return {c["llmqType"] for c in s.get("minableCommitments", []) + if c["quorumHash"] == quorum_hash and c["quorumPublicKey"] != '0' * 96} + + def node_has_quorum_commitment(self, node, quorum_hash, llmq_type): + return llmq_type in self.node_quorum_commitment_types(node, quorum_hash) def wait_for_quorum_commitment(self, quorum_hash, mninfos, llmq_type=100, timeout=15): def check_dkg_comitments(): @@ -2175,6 +2168,20 @@ def check_dkg_comitments(): self.wait_until(check_dkg_comitments, timeout=timeout) + def wait_for_quorum_commitments_on_miner(self, quorum_hash, mninfos, timeout=15): + # The final-commitment block carries a commitment for every llmq type whose mining + # window is open, so a commitment that has not reached the mining node in time is + # mined as a null one and its quorum is silently skipped for the whole cycle. Only + # commitments the masternodes actually produced are awaited, so a type whose DKG + # legitimately produced nothing does not hold this up. + def check_miner_commitments(): + expected = set() + for mn in mninfos: + expected |= self.node_quorum_commitment_types(mn.get_node(self), quorum_hash) + return expected <= self.node_quorum_commitment_types(self.nodes[0], quorum_hash) + + self.wait_until(check_miner_commitments, timeout=timeout) + def wait_for_quorum_list(self, quorum_hash, nodes, timeout=15, llmq_type_name="llmq_test"): def wait_func(): return quorum_hash in self.nodes[0].quorum('list')[llmq_type_name] @@ -2285,8 +2292,8 @@ def mine_quorum(self, llmq_type_name="llmq_test", llmq_type=100, expected_connec self.log.info("Waiting final commitment") self.wait_for_quorum_commitment(q, mninfos_online, llmq_type=llmq_type) - self.log.info("Waiting final commitment on mining node") - self.wait_until(lambda: self.node_has_quorum_commitment(self.nodes[0], q, llmq_type), timeout=15) + 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) From 03ff1ffec972131fbc348acd8ec1219507db94b5 Mon Sep 17 00:00:00 2001 From: pasta Date: Sat, 1 Aug 2026 20:18:41 -0500 Subject: [PATCH 2/2] test: ignore MnEHF signal txs in asset lock mempool accounting check_mempool_size() compared getmempoolinfo()['size'] against self.mempool_size, which only models the transactions the test itself submits. Masternodes submit the one-shot V24 MnEHF signal transaction on their own as soon as a quorum able to sign it exists (src/llmq/ehf_signals.cpp:118), at a moment the test does not control. When that lands between the test's last generate() and a check expecting an empty mempool, the test fails with 'not(1 == 0)'. This is the failure mode still reported on dashpay/dash#7310 after #7411 closed it, and it still reproduces on current develop. Count only the transactions this test submits. Deliberately not a wait_until on the mempool size: that would also pass if an asset unlock transaction wrongly lingered, which is what the assertion exists to catch. --- test/functional/feature_asset_locks.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/test/functional/feature_asset_locks.py b/test/functional/feature_asset_locks.py index 990975a84c8c..4ddaec78efe3 100755 --- a/test/functional/feature_asset_locks.py +++ b/test/functional/feature_asset_locks.py @@ -45,6 +45,7 @@ from test_framework.wallet_util import bytes_to_wif llmq_type_test = 106 # LLMQType::LLMQ_TEST_PLATFORM +MNEHF_SIGNAL_TX_TYPE = 7 # TRANSACTION_MNHF_SIGNAL tiny_amount = int(Decimal("0.0007") * COIN) blocks_in_one_day = 100 HEIGHT_DIFF_EXPIRING = 48 @@ -175,9 +176,13 @@ def validate_credit_pool_balance(self, expected = None, block_hash = None): return expected def check_mempool_size(self): + # Masternodes submit the MnEHF signal transaction on their own as soon as a quorum + # able to sign it exists, so it is not part of what this test puts in the mempool. self.sync_mempools() for node in self.nodes: - assert_equal(node.getmempoolinfo()['size'], self.mempool_size) + own = [txid for txid in node.getrawmempool() + if node.getrawtransaction(txid, 1)['type'] != MNEHF_SIGNAL_TX_TYPE] + assert_equal(len(own), self.mempool_size) def check_mempool_result(self, result_expected, tx): """Wrapper to check result of testmempoolaccept on node_0's mempool"""