From b052e852af010d3caa94ebe33dce2c35add2dd5e Mon Sep 17 00:00:00 2001 From: pasta Date: Wed, 29 Jul 2026 22:31:41 -0500 Subject: [PATCH 1/6] test: add wallet_dash_rpcs.py covering wallet-mode-divergent Dash RPC paths Dash-specific RPCs touch the wallet in a small number of places: gobject prepare funds the proposal collateral from the wallet, gobject vote-many/vote-alias look up voting keys via CheckWalletOwnsKey/IsMine and sign via CWallet::SignGovernanceVote -> SignMessage (SPKM dispatch), and the protx register/update family funds, signs and submits special transactions through the wallet, including the external-collateral register_prepare + signmessage + register_submit path. Cover exactly this surface in a dedicated test that runs in both --legacy-wallet and --descriptors modes using a small 2-masternode topology with no quorums and no superblock cycles (~13s per mode locally). This will allow the consensus-heavy tests that currently run twice only to keep a single wallet mode without losing wallet coverage. Co-Authored-By: Claude Fable 5 --- test/functional/test_runner.py | 2 + test/functional/wallet_dash_rpcs.py | 168 ++++++++++++++++++++++++++++ 2 files changed, 170 insertions(+) create mode 100755 test/functional/wallet_dash_rpcs.py diff --git a/test/functional/test_runner.py b/test/functional/test_runner.py index 5d5399043579..23fbea2072bf 100755 --- a/test/functional/test_runner.py +++ b/test/functional/test_runner.py @@ -146,6 +146,8 @@ 'wallet_import_rescan.py --legacy-wallet', 'wallet_backup.py --legacy-wallet', 'wallet_backup.py --descriptors', + 'wallet_dash_rpcs.py --legacy-wallet', + 'wallet_dash_rpcs.py --descriptors', 'p2p_tx_download.py', 'wallet_avoidreuse.py --legacy-wallet', 'wallet_avoidreuse.py --descriptors', diff --git a/test/functional/wallet_dash_rpcs.py b/test/functional/wallet_dash_rpcs.py new file mode 100755 index 000000000000..7983beffd374 --- /dev/null +++ b/test/functional/wallet_dash_rpcs.py @@ -0,0 +1,168 @@ +#!/usr/bin/env python3 +# Copyright (c) 2026 The Dash Core developers +# Distributed under the MIT software license, see the accompanying +# file COPYING or http://www.opensource.org/licenses/mit-license.php. +"""Test wallet-dependent Dash RPC paths. + +Covers the wallet-mode-divergent surface of Dash-specific RPCs so that +consensus-heavy tests (feature_governance.py, feature_governance_cl.py, +feature_dip3_deterministicmns.py) only need to run in a single wallet mode: + +- gobject prepare (wallet-funded collateral), list-prepared, submit +- gobject vote-many and vote-alias (CheckWalletOwnsKey/IsMine and + CWallet::SignGovernanceVote -> SignMessage SPKM dispatch) +- protx register_fund (wallet-funded collateral) +- protx register_prepare + signmessage + register_submit (external + collateral signing path) +- protx update_service and protx update_registrar + +This test runs in both --legacy-wallet and --descriptors modes. +""" + +from test_framework.governance import prepare_object +from test_framework.messages import uint256_to_string +from test_framework.test_framework import ( + DashTestFramework, + MasternodeInfo, +) +from test_framework.util import assert_equal, p2p_port, softfork_active + + +class WalletDashRPCsTest(DashTestFramework): + def add_options(self, parser): + self.add_wallet_options(parser) + + def set_test_params(self): + self.set_dash_test_params(3, 2) + + def prepare_unstarted_mn(self, idx) -> MasternodeInfo: + mn = MasternodeInfo(evo=False, legacy=(not softfork_active(self.nodes[0], 'v19'))) + mn.generate_addresses(self.nodes[0]) + mn.set_params(nodePort=p2p_port(idx)) + return mn + + def confirm_tx(self, txid): + assert txid in self.nodes[0].getrawmempool() + self.bump_mocktime(1) + block_hash = self.generate(self.nodes[0], 1)[0] + assert txid in self.nodes[0].getblock(block_hash)["tx"] + + def run_test(self): + # There are no quorums in this test, so txes can never be InstantSend-locked + # and would not be mined until they are 10 minutes old. Disable InstantSend + # to make them mineable right away. + self.nodes[0].sporkupdate("SPORK_2_INSTANTSEND_ENABLED", 4070908800) + self.wait_for_sporks_same() + + self.test_gobject_wallet_paths() + self.test_protx_register_fund() + self.test_protx_register_external() + self.test_protx_update_service() + self.test_protx_update_registrar() + + def test_gobject_wallet_paths(self): + node = self.nodes[0] + self.log.info("Test gobject prepare (wallet collateral funding)") + assert_equal(len(node.gobject("list-prepared")), 0) + proposal_time = self.mocktime + payout_address = node.getnewaddress() + prepared = prepare_object(node, 1, uint256_to_string(0), proposal_time, 1, "wallet_test_proposal", 1, payout_address) + self.confirm_tx(prepared["collateralHash"]) + # Governance collateral needs GOVERNANCE_FEE_CONFIRMATIONS (6) confirmations + self.bump_mocktime(5) + self.generate(node, 5) + + self.log.info("Test gobject list-prepared and submit") + assert_equal(len(node.gobject("list-prepared")), 1) + assert_equal(len(node.gobject("list")), 0) + proposal_hash = node.gobject("submit", "0", 1, proposal_time, prepared["hex"], prepared["collateralHash"]) + assert_equal(len(node.gobject("list")), 1) + self.wait_until(lambda: len(self.mninfo[0].get_node(self).gobject("list")) == 1, timeout=10) + + self.log.info("Test gobject vote-alias and vote-many (wallet vote signing)") + node.gobject("vote-alias", proposal_hash, "funding", "no", self.mninfo[0].proTxHash) + # The repeated vote for mninfo[0] is rejected due to the vote rate limit, + # so vote-many only adds votes for the remaining masternodes + node.gobject("vote-many", proposal_hash, "funding", "yes") + assert_equal(node.gobject("get", proposal_hash)["FundingResult"]["YesCount"], self.mn_count - 1) + assert_equal(node.gobject("get", proposal_hash)["FundingResult"]["NoCount"], 1) + assert_equal(node.gobject("count")["votes"], self.mn_count) + + self.log.info("Make sure wallet-signed votes are accepted by other nodes") + mn_node = self.mninfo[0].get_node(self) + self.wait_until(lambda: mn_node.gobject("get", proposal_hash)["FundingResult"]["YesCount"] == self.mn_count - 1, timeout=10) + self.wait_until(lambda: mn_node.gobject("get", proposal_hash)["FundingResult"]["NoCount"] == 1, timeout=10) + + def test_protx_register_fund(self): + node = self.nodes[0] + self.log.info("Test protx register_fund (wallet-funded collateral)") + self.fund_mn = self.prepare_unstarted_mn(len(self.nodes) + 1) + node.sendtoaddress(self.fund_mn.fundsAddr, self.fund_mn.get_collateral_value() + 0.001) + self.bump_mocktime(1) + self.generate(node, 1) + txid = self.fund_mn.register_fund(node, submit=True) + assert txid is not None + self.confirm_tx(txid) + vout = self.fund_mn.get_collateral_vout(node, txid) + self.fund_mn.set_params(proTxHash=txid, collateral_txid=txid, collateral_vout=vout) + assert txid in node.protx("list", "registered") + assert_equal(node.protx("info", txid)["collateralAddress"], self.fund_mn.collateral_address) + assert "%s-%d" % (txid, vout) in node.masternode("list") + + def test_protx_register_external(self): + node = self.nodes[0] + self.log.info("Test protx register_prepare + signmessage + register_submit (external collateral)") + mn = self.prepare_unstarted_mn(len(self.nodes) + 2) + collateral_txid = node.sendtoaddress(mn.collateral_address, mn.get_collateral_value()) + node.sendtoaddress(mn.fundsAddr, 0.001) + self.bump_mocktime(1) + self.generate(node, 1) + collateral_vout = mn.get_collateral_vout(node, collateral_txid) + mn.set_params(collateral_txid=collateral_txid, collateral_vout=collateral_vout) + + command = "register_prepare_legacy" if mn.legacy else "register_prepare" + prepared = node.protx(command, collateral_txid, collateral_vout, f'127.0.0.1:{mn.nodePort}', + mn.ownerAddr, mn.pubKeyOperator, mn.votingAddr, 0, mn.rewards_address, mn.fundsAddr) + assert_equal(prepared["collateralAddress"], mn.collateral_address) + signature = node.signmessage(prepared["collateralAddress"], prepared["signMessage"]) + protx_hash = node.protx("register_submit", prepared["tx"], signature) + mn.set_params(proTxHash=protx_hash) + self.confirm_tx(protx_hash) + assert protx_hash in node.protx("list", "registered") + assert_equal(node.protx("info", protx_hash)["collateralHash"], collateral_txid) + assert "%s-%d" % (collateral_txid, collateral_vout) in node.masternode("list") + + def test_protx_update_service(self): + node = self.nodes[0] + self.log.info("Test protx update_service") + mn = self.fund_mn + node.sendtoaddress(mn.fundsAddr, 0.001) + self.bump_mocktime(1) + self.generate(node, 1) + new_address = f'127.0.0.2:{mn.nodePort}' + txid = mn.update_service(node, submit=True, addrs_core_p2p=[new_address]) + assert txid is not None + self.confirm_tx(txid) + assert_equal(node.protx("info", mn.proTxHash)["state"]["addresses"]["core_p2p"][0], new_address) + + def test_protx_update_registrar(self): + node = self.nodes[0] + self.log.info("Test protx update_registrar (owner key wallet signing)") + mn = self.fund_mn + node.sendtoaddress(mn.fundsAddr, 0.001) + self.bump_mocktime(1) + self.generate(node, 1) + old_state = node.protx("info", mn.proTxHash)["state"] + new_voting_address = node.getnewaddress() + assert old_state["votingAddress"] != new_voting_address + txid = mn.update_registrar(node, submit=True, pubKeyOperator="", votingAddr=new_voting_address, + rewards_address="", fundsAddr=mn.fundsAddr) + assert txid is not None + self.confirm_tx(txid) + new_state = node.protx("info", mn.proTxHash)["state"] + assert_equal(new_state["votingAddress"], new_voting_address) + assert_equal(new_state["payoutAddress"], old_state["payoutAddress"]) + + +if __name__ == '__main__': + WalletDashRPCsTest().main() From 60f0c3845658586366e2e822b7e62da80a2bc182 Mon Sep 17 00:00:00 2001 From: pasta Date: Wed, 29 Jul 2026 22:32:20 -0500 Subject: [PATCH 2/6] test: run governance functional tests in a single wallet mode feature_governance.py and feature_governance_cl.py were run in both --legacy-wallet and --descriptors modes because they were the regression tests for descriptor-wallet governance vote signing (dash#6003, dash#6094). Their coverage is almost entirely consensus-side and wallet-agnostic (superblock budgets, trigger creation and voting, ChainLock interaction); the wallet-mode-divergent surface (gobject prepare collateral funding, vote-many/vote-alias key lookup and vote signing) is now covered in both wallet modes by wallet_dash_rpcs.py, so run the governance tests in descriptors mode only. Saves roughly 110 seconds of serial functional-test time per CI job. Co-Authored-By: Claude Fable 5 --- test/functional/test_runner.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/test/functional/test_runner.py b/test/functional/test_runner.py index 23fbea2072bf..c26f638e7334 100755 --- a/test/functional/test_runner.py +++ b/test/functional/test_runner.py @@ -131,9 +131,7 @@ 'feature_dip4_coinbasemerkleroots.py', # NOTE: needs dash_hash to pass 'feature_mnehf.py', # NOTE: needs dash_hash to pass 'feature_masternode_params.py', # NOTE: needs dash_hash to pass - 'feature_governance.py --legacy-wallet', 'feature_governance.py --descriptors', - 'feature_governance_cl.py --legacy-wallet', 'feature_governance_cl.py --descriptors', 'rpc_verifyislock.py', 'feature_notifications.py', From 698079525d755394a6f1490c7f17575302a0503e Mon Sep 17 00:00:00 2001 From: pasta Date: Wed, 29 Jul 2026 22:32:34 -0500 Subject: [PATCH 3/6] test: run feature_dip3_deterministicmns in a single wallet mode feature_dip3_deterministicmns.py was run in both wallet modes for the same historical reason as the governance tests: it was part of the descriptor-wallet support regression suite (dash#6003, dash#6094). The test exercises consensus-side deterministic masternode list behavior (registration heights, collateral spends, reorgs, payment enforcement) which is wallet-mode-agnostic. The wallet-facing protx paths it goes through (register_fund, register, update_service, update_registrar and external-collateral signing) are covered in both wallet modes by wallet_dash_rpcs.py, so run it in descriptors mode only. Saves roughly 100 seconds of serial functional-test time per CI job. Co-Authored-By: Claude Fable 5 --- test/functional/test_runner.py | 1 - 1 file changed, 1 deletion(-) diff --git a/test/functional/test_runner.py b/test/functional/test_runner.py index c26f638e7334..fa907cf3469b 100755 --- a/test/functional/test_runner.py +++ b/test/functional/test_runner.py @@ -117,7 +117,6 @@ 'feature_llmq_chainlocks.py', # NOTE: needs dash_hash to pass 'feature_llmq_simplepose.py', # NOTE: needs dash_hash to pass 'feature_llmq_simplepose.py --disable-spork23', # NOTE: needs dash_hash to pass - 'feature_dip3_deterministicmns.py --legacy-wallet', # NOTE: needs dash_hash to pass 'feature_dip3_deterministicmns.py --descriptors', # NOTE: needs dash_hash to pass 'feature_masternode_payout_shares.py', 'feature_llmq_signing.py', # NOTE: needs dash_hash to pass From 436bcfaf339b6fcccc92ff357a479a5e3b9e392c Mon Sep 17 00:00:00 2001 From: pasta Date: Wed, 29 Jul 2026 22:36:11 -0500 Subject: [PATCH 4/6] test: merge spork21 variant of feature_llmq_signing into a single run feature_llmq_signing.py ran twice, once with spork21 off and once with --spork21, duplicating the whole signing-session flow (quorum mining, sign/verify RPCs, recovered-sig expiration) which does not depend on the spork. Run the shared flow once with spork21 off, then enable SPORK_21_QUORUM_ALL_CONNECTED mid-test, mine one more quorum and run the spork21-only sections on it: all-connected topology and symmetric QSENDRECSIGS checks, the submit=false RPC parameter with QSIGSHARE P2P share submission to the recovery member, and the recovery-member isolation scenario. mine_quorum() reads spork state at call time, so expected connection counts adjust automatically. The only permutation lost is spork21 being active from the very first DKG, which is already covered by feature_llmq_connections.py (it mines quorums right after activating spork21 on a fresh quorum-less chain and verifies member connections and probes). Saves roughly 60 seconds of serial functional-test time per CI job. Co-Authored-By: Claude Fable 5 --- test/functional/feature_llmq_signing.py | 138 +++++++++++++----------- test/functional/test_runner.py | 1 - 2 files changed, 74 insertions(+), 65 deletions(-) diff --git a/test/functional/feature_llmq_signing.py b/test/functional/feature_llmq_signing.py index 61db6c20942d..9e0e225c525e 100755 --- a/test/functional/feature_llmq_signing.py +++ b/test/functional/feature_llmq_signing.py @@ -27,22 +27,18 @@ def set_test_params(self): def add_options(self, parser): self.add_wallet_options(parser) - parser.add_argument("--spork21", dest="spork21", default=False, action="store_true", - help="Test with spork21 enabled") def run_test(self): + # The first part of this test runs with spork21 off, the second part + # enables it mid-test and exercises the spork21-only paths on quorums + # mined after that. spork21 being active from the very first DKG is + # covered by feature_llmq_connections.py. self.nodes[0].sporkupdate("SPORK_17_QUORUM_DKG_ENABLED", 0) - if self.options.spork21: - self.nodes[0].sporkupdate("SPORK_21_QUORUM_ALL_CONNECTED", 0) self.wait_for_sporks_same() self.mine_quorum() - if self.options.spork21: - assert self.mninfo[0].get_node(self).getconnectioncount() == self.llmq_size - self.assert_qsendrecsigs_symmetric() - id = "0000000000000000000000000000000000000000000000000000000000000001" msgHash = "0000000000000000000000000000000000000000000000000000000000000002" msgHashConflict = "0000000000000000000000000000000000000000000000000000000000000003" @@ -77,43 +73,12 @@ def assert_sigs_nochange(hasrecsigs, isconflicting1, isconflicting2, timeout): quorumHash = self.mninfo[1].get_node(self).quorum("selectquorum", q_type, id)["quorumHash"] assert self.mninfo[1].get_node(self).quorum("sign", q_type, id, msgHash, quorumHash) assert_sigs_nochange(False, False, False, 3) - # Sign third share and test optional submit parameter if spork21 is enabled, should result in recovered sig - # and conflict for msgHashConflict - if self.options.spork21: - # 1. Providing an invalid quorum hash and set submit=false, should throw an error - assert_raises_rpc_error(-8, 'quorum not found', self.mninfo[2].get_node(self).quorum, "sign", q_type, id, msgHash, id, False) - # 2. Providing a valid quorum hash and set submit=false, should return a valid sigShare object - sig_share_rpc_1 = self.mninfo[2].get_node(self).quorum("sign", q_type, id, msgHash, quorumHash, False) - sig_share_rpc_2 = self.mninfo[2].get_node(self).quorum("sign", q_type, id, msgHash, "", False) - assert_equal(sig_share_rpc_1, sig_share_rpc_2) - assert_sigs_nochange(False, False, False, 3) - # 3. Sending the sig share received from RPC to the recovery member through P2P interface, should result - # in a recovered sig - sig_share = CSigShare() - sig_share.llmqType = int(sig_share_rpc_1["llmqType"]) - sig_share.quorumHash = int(sig_share_rpc_1["quorumHash"], 16) - sig_share.quorumMember = int(sig_share_rpc_1["quorumMember"]) - sig_share.id = int(sig_share_rpc_1["id"], 16) - sig_share.msgHash = int(sig_share_rpc_1["msgHash"], 16) - sig_share.sigShare = bytes.fromhex(sig_share_rpc_1["signature"]) - for mn in self.mninfo: # type: MasternodeInfo - assert mn.get_node(self).getconnectioncount() == self.llmq_size - # Get the current recovery member of the quorum - q = self.nodes[0].quorum('selectquorum', q_type, id) - mn: MasternodeInfo = self.get_mninfo(q['recoveryMembers'][0]) - # Open a P2P connection to it - p2p_interface = mn.get_node(self).add_p2p_connection(P2PInterface()) - # Send the last required QSIGSHARE message to the recovery member - p2p_interface.send_message(msg_qsigshare([sig_share])) - else: - # If spork21 is not enabled just sign regularly - self.mninfo[2].get_node(self).quorum("sign", q_type, id, msgHash) + # Sign third share regularly, should result in recovered sig and conflict for msgHashConflict. + # The optional submit parameter is spork21-only and is exercised after spork21 is enabled below. + self.mninfo[2].get_node(self).quorum("sign", q_type, id, msgHash) wait_for_sigs(True, False, True, 15) - if self.options.spork21: - mn.get_node(self).disconnect_p2ps() - # Test `quorum verify` rpc node = self.mninfo[0].get_node(self) recsig = node.quorum("getrecsig", q_type, id, msgHash) @@ -177,29 +142,74 @@ def assert_sigs_nochange(hasrecsigs, isconflicting1, isconflicting2, timeout): self.mninfo[i].get_node(self).quorum("sign", q_type, id, msgHash) wait_for_sigs(True, False, True, 15) - if self.options.spork21: - id = uint256_to_string(request_id + 1) - - # Isolate the node that is responsible for the recovery of a signature and assert that recovery fails - q = self.nodes[0].quorum('selectquorum', q_type, id) - mn: MasternodeInfo = self.get_mninfo(q['recoveryMembers'][0]) - mn.get_node(self).setnetworkactive(False) - self.wait_until(lambda: mn.get_node(self).getconnectioncount() == 0) - for i in range(4): - self.mninfo[i].get_node(self).quorum("sign", q_type, id, msgHash) - assert_sigs_nochange(False, False, False, 3) - # Need to re-connect so that it later gets the recovered sig - mn.get_node(self).setnetworkactive(True) - self.connect_nodes(mn.nodeIdx, 0) - force_finish_mnsync(mn.get_node(self)) - # Make sure intra-quorum connections were also restored - self.bump_mocktime(1) # need this to bypass quorum connection retry timeout + self.log.info("Enable SPORK_21_QUORUM_ALL_CONNECTED and mine one more quorum") + self.nodes[0].sporkupdate("SPORK_21_QUORUM_ALL_CONNECTED", 0) + self.wait_for_sporks_same() + self.mine_quorum() + + for mn in self.mninfo: # type: MasternodeInfo self.wait_until(lambda: mn.get_node(self).getconnectioncount() == self.llmq_size, timeout=10) - mn.get_node(self).ping() - self.wait_until(lambda: all('pingwait' not in peer for peer in mn.get_node(self).getpeerinfo())) - # Let 2 seconds pass so that the next node is used for recovery, which should succeed - self.bump_mocktime(2) - wait_for_sigs(True, False, True, 2) + self.assert_qsendrecsigs_symmetric() + + self.log.info("Test the optional submit parameter and QSIGSHARE P2P submission") + id = uint256_to_string(request_id + 1) + # Sign first two shares regularly, using the optional quorumHash parameter for the second one + self.mninfo[0].get_node(self).quorum("sign", q_type, id, msgHash) + quorumHash = self.mninfo[1].get_node(self).quorum("selectquorum", q_type, id)["quorumHash"] + assert self.mninfo[1].get_node(self).quorum("sign", q_type, id, msgHash, quorumHash) + assert_sigs_nochange(False, False, False, 3) + # Sign third share and test the optional submit parameter, should result in recovered sig + # and conflict for msgHashConflict + # 1. Providing an invalid quorum hash and set submit=false, should throw an error + assert_raises_rpc_error(-8, 'quorum not found', self.mninfo[2].get_node(self).quorum, "sign", q_type, id, msgHash, id, False) + # 2. Providing a valid quorum hash and set submit=false, should return a valid sigShare object + sig_share_rpc_1 = self.mninfo[2].get_node(self).quorum("sign", q_type, id, msgHash, quorumHash, False) + sig_share_rpc_2 = self.mninfo[2].get_node(self).quorum("sign", q_type, id, msgHash, "", False) + assert_equal(sig_share_rpc_1, sig_share_rpc_2) + assert_sigs_nochange(False, False, False, 3) + # 3. Sending the sig share received from RPC to the recovery member through P2P interface, should result + # in a recovered sig + sig_share = CSigShare() + sig_share.llmqType = int(sig_share_rpc_1["llmqType"]) + sig_share.quorumHash = int(sig_share_rpc_1["quorumHash"], 16) + sig_share.quorumMember = int(sig_share_rpc_1["quorumMember"]) + sig_share.id = int(sig_share_rpc_1["id"], 16) + sig_share.msgHash = int(sig_share_rpc_1["msgHash"], 16) + sig_share.sigShare = bytes.fromhex(sig_share_rpc_1["signature"]) + # Get the current recovery member of the quorum + q = self.nodes[0].quorum('selectquorum', q_type, id) + mn: MasternodeInfo = self.get_mninfo(q['recoveryMembers'][0]) + # Open a P2P connection to it + p2p_interface = mn.get_node(self).add_p2p_connection(P2PInterface()) + # Send the last required QSIGSHARE message to the recovery member + p2p_interface.send_message(msg_qsigshare([sig_share])) + + wait_for_sigs(True, False, True, 15) + + mn.get_node(self).disconnect_p2ps() + + id = uint256_to_string(request_id + 2) + + # Isolate the node that is responsible for the recovery of a signature and assert that recovery fails + q = self.nodes[0].quorum('selectquorum', q_type, id) + mn = self.get_mninfo(q['recoveryMembers'][0]) + mn.get_node(self).setnetworkactive(False) + self.wait_until(lambda: mn.get_node(self).getconnectioncount() == 0) + for i in range(4): + self.mninfo[i].get_node(self).quorum("sign", q_type, id, msgHash) + assert_sigs_nochange(False, False, False, 3) + # Need to re-connect so that it later gets the recovered sig + mn.get_node(self).setnetworkactive(True) + self.connect_nodes(mn.nodeIdx, 0) + force_finish_mnsync(mn.get_node(self)) + # Make sure intra-quorum connections were also restored + self.bump_mocktime(1) # need this to bypass quorum connection retry timeout + self.wait_until(lambda: mn.get_node(self).getconnectioncount() == self.llmq_size, timeout=10) + mn.get_node(self).ping() + self.wait_until(lambda: all('pingwait' not in peer for peer in mn.get_node(self).getpeerinfo())) + # Let 2 seconds pass so that the next node is used for recovery, which should succeed + self.bump_mocktime(2) + wait_for_sigs(True, False, True, 2) def assert_qsendrecsigs_symmetric(self): # If only one direction's QSENDRECSIGS arrives, the receiving side keeps diff --git a/test/functional/test_runner.py b/test/functional/test_runner.py index fa907cf3469b..445a26e17554 100755 --- a/test/functional/test_runner.py +++ b/test/functional/test_runner.py @@ -120,7 +120,6 @@ 'feature_dip3_deterministicmns.py --descriptors', # NOTE: needs dash_hash to pass 'feature_masternode_payout_shares.py', 'feature_llmq_signing.py', # NOTE: needs dash_hash to pass - 'feature_llmq_signing.py --spork21', # 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_is_cl_conflicts.py', # NOTE: needs dash_hash to pass From 95996baf4e4ea6afa305654b8d75ca125460d33d Mon Sep 17 00:00:00 2001 From: pasta Date: Wed, 29 Jul 2026 22:39:37 -0500 Subject: [PATCH 5/6] test: skip non-spork23-gated banning section in simplepose spork23 variant feature_llmq_simplepose.py runs twice, with spork23 enabled and disabled. The first section (test_banning via isolate_mn) exercises the DKG contribution-miss ban path (MarkBadMember -> PoSePunish), which is not gated on spork23: spork23 only gates VerifyConnectionAndMinProtoVersions and connection probes. Running that section in the --disable-spork23 variant repeated identical coverage, so skip it there together with its repair_masternodes call. Instead, mine a single quorum in normal conditions so that the spork23-specific sections below (no banning for unreachable or outdated nodes when PoSe checks are off) start from the same state as in the spork23-enabled run: an existing quorum and all masternodes healthy. Those sections are otherwise unchanged and the skipped section does not feed any state into them: masternodes are unbanned either way once repair_masternodes has run and expected contributor counts are derived from mninfo at call time. Also move the --disable-spork23 entry further down the runner list to match its reduced runtime. Saves roughly 20-30 seconds of serial functional-test time per CI job. Co-Authored-By: Claude Fable 5 --- test/functional/feature_llmq_simplepose.py | 18 +++++++++++++++--- test/functional/test_runner.py | 2 +- 2 files changed, 16 insertions(+), 4 deletions(-) diff --git a/test/functional/feature_llmq_simplepose.py b/test/functional/feature_llmq_simplepose.py index 274e71556cab..388aac9eace6 100755 --- a/test/functional/feature_llmq_simplepose.py +++ b/test/functional/feature_llmq_simplepose.py @@ -45,10 +45,22 @@ def run_test(self): self.nodes[0].sporkupdate("SPORK_17_QUORUM_DKG_ENABLED", 0) self.wait_for_sporks_same() - # Lets isolate MNs one by one and verify that punishment/banning happens - self.test_banning(self.isolate_mn, 2) + if not self.options.disable_spork23: + # Lets isolate MNs one by one and verify that punishment/banning happens + self.test_banning(self.isolate_mn, 2) - self.repair_masternodes(False) + self.repair_masternodes(False) + else: + # The contribution-miss ban path (MarkBadMember -> PoSePunish) is not + # gated on spork23 (spork23 only gates connection/proto-version checks + # and probes), so it behaves identically with spork23 disabled and is + # already covered by the spork23-enabled run of this test. + self.log.info("Skipping contribution-miss banning, not affected by spork23") + # Mine one quorum in normal conditions so that the sections below start + # from the same state as in the spork23-enabled run: an existing quorum + # and all masternodes healthy. + self.reset_probe_timeouts() + self.mine_quorum() self.nodes[0].sporkupdate("SPORK_21_QUORUM_ALL_CONNECTED", 0) self.wait_for_sporks_same() diff --git a/test/functional/test_runner.py b/test/functional/test_runner.py index 445a26e17554..0f129b6526c6 100755 --- a/test/functional/test_runner.py +++ b/test/functional/test_runner.py @@ -116,10 +116,10 @@ 'feature_llmq_is_retroactive.py', # NOTE: needs dash_hash to pass 'feature_llmq_chainlocks.py', # NOTE: needs dash_hash to pass 'feature_llmq_simplepose.py', # NOTE: needs dash_hash to pass - 'feature_llmq_simplepose.py --disable-spork23', # NOTE: needs dash_hash to pass 'feature_dip3_deterministicmns.py --descriptors', # NOTE: needs dash_hash to pass 'feature_masternode_payout_shares.py', 'feature_llmq_signing.py', # NOTE: needs dash_hash to pass + 'feature_llmq_simplepose.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_is_cl_conflicts.py', # NOTE: needs dash_hash to pass From 850aeba56fd1577abe00cc3f0b94f3d4b2dc3ecf Mon Sep 17 00:00:00 2001 From: UdjinM6 Date: Fri, 31 Jul 2026 19:02:34 +0300 Subject: [PATCH 6/6] test: address review feedback on wallet-mode segregation - feature_llmq_signing.py: the comment justifying the removal of the --spork21 variant named feature_llmq_connections.py, which mines three quorums before activating spork21 and therefore does not cover the first-DKG case. feature_llmq_data_recovery.py does, so name that one. - feature_llmq_signing.py: the intra-quorum connections here predate the spork21 activation, unlike on a chain that runs with it from the first DKG, so bump mocktime while waiting for the all-connected topology to settle instead of polling a fixed connection count without bumping. - wallet_dash_rpcs.py: cover "protx register" too. It proves collateral ownership via CWallet::SignMessage (rpc/evo.cpp), a wallet-mode-divergent path that feature_dip3_deterministicmns.py exercised in legacy mode before this series dropped its --legacy-wallet run. - wallet_dash_rpcs.py: assert on the per-masternode result of vote-alias and vote-many instead of inferring the rate-limited re-vote from the aggregate counts alone. - wallet_dash_rpcs.py: drop pubKeyOperator="" and rewards_address="" from the update_registrar call. Both are falsy and fall back to the masternode's existing values, so they read as "clear this field" while meaning "keep it", making the payoutAddress assertion trivially true. - wallet_dash_rpcs.py: pass the registered masternode between the protx subtests explicitly rather than via an implicit self.fund_mn attribute. Co-Authored-By: Claude Opus 5 --- test/functional/feature_llmq_signing.py | 13 ++++- test/functional/wallet_dash_rpcs.py | 74 +++++++++++++++++-------- 2 files changed, 60 insertions(+), 27 deletions(-) diff --git a/test/functional/feature_llmq_signing.py b/test/functional/feature_llmq_signing.py index 9e0e225c525e..8511446ef496 100755 --- a/test/functional/feature_llmq_signing.py +++ b/test/functional/feature_llmq_signing.py @@ -33,7 +33,8 @@ def run_test(self): # The first part of this test runs with spork21 off, the second part # enables it mid-test and exercises the spork21-only paths on quorums # mined after that. spork21 being active from the very first DKG is - # covered by feature_llmq_connections.py. + # covered by feature_llmq_data_recovery.py, which enables it at the top + # of run_test on a fresh chain, before any DKG has run. self.nodes[0].sporkupdate("SPORK_17_QUORUM_DKG_ENABLED", 0) self.wait_for_sporks_same() @@ -147,8 +148,14 @@ def assert_sigs_nochange(hasrecsigs, isconflicting1, isconflicting2, timeout): self.wait_for_sporks_same() self.mine_quorum() - for mn in self.mninfo: # type: MasternodeInfo - self.wait_until(lambda: mn.get_node(self).getconnectioncount() == self.llmq_size, timeout=10) + # Unlike a chain that runs with spork21 from the very first DKG, the intra-quorum + # connections here predate the spork, so bump mocktime while waiting to bypass the + # quorum connection retry timeout and let the all-connected topology settle. + def all_mns_fully_connected(): + self.bump_mocktime(1) + return all(mn.get_node(self).getconnectioncount() == self.llmq_size for mn in self.mninfo) + + self.wait_until(all_mns_fully_connected, timeout=30, sleep=1) self.assert_qsendrecsigs_symmetric() self.log.info("Test the optional submit parameter and QSIGSHARE P2P submission") diff --git a/test/functional/wallet_dash_rpcs.py b/test/functional/wallet_dash_rpcs.py index 7983beffd374..ecf72572985a 100755 --- a/test/functional/wallet_dash_rpcs.py +++ b/test/functional/wallet_dash_rpcs.py @@ -12,6 +12,8 @@ - gobject vote-many and vote-alias (CheckWalletOwnsKey/IsMine and CWallet::SignGovernanceVote -> SignMessage SPKM dispatch) - protx register_fund (wallet-funded collateral) +- protx register (collateral owned by the wallet, payload signed via + CWallet::SignMessage) - protx register_prepare + signmessage + register_submit (external collateral signing path) - protx update_service and protx update_registrar @@ -29,12 +31,12 @@ class WalletDashRPCsTest(DashTestFramework): - def add_options(self, parser): - self.add_wallet_options(parser) - def set_test_params(self): self.set_dash_test_params(3, 2) + def add_options(self, parser): + self.add_wallet_options(parser) + def prepare_unstarted_mn(self, idx) -> MasternodeInfo: mn = MasternodeInfo(evo=False, legacy=(not softfork_active(self.nodes[0], 'v19'))) mn.generate_addresses(self.nodes[0]) @@ -55,10 +57,11 @@ def run_test(self): self.wait_for_sporks_same() self.test_gobject_wallet_paths() - self.test_protx_register_fund() + funded_mn = self.test_protx_register_fund() + self.test_protx_register_own_collateral() self.test_protx_register_external() - self.test_protx_update_service() - self.test_protx_update_registrar() + self.test_protx_update_service(funded_mn) + self.test_protx_update_registrar(funded_mn) def test_gobject_wallet_paths(self): node = self.nodes[0] @@ -80,10 +83,15 @@ def test_gobject_wallet_paths(self): self.wait_until(lambda: len(self.mninfo[0].get_node(self).gobject("list")) == 1, timeout=10) self.log.info("Test gobject vote-alias and vote-many (wallet vote signing)") - node.gobject("vote-alias", proposal_hash, "funding", "no", self.mninfo[0].proTxHash) - # The repeated vote for mninfo[0] is rejected due to the vote rate limit, - # so vote-many only adds votes for the remaining masternodes - node.gobject("vote-many", proposal_hash, "funding", "yes") + alias_result = node.gobject("vote-alias", proposal_hash, "funding", "no", self.mninfo[0].proTxHash) + assert_equal(alias_result["detail"][self.mninfo[0].proTxHash]["result"], "success") + # vote-many signs for every masternode whose voting key is in the wallet, but the + # repeated vote for mninfo[0] is rejected by the GOVERNANCE_UPDATE_MIN rate limit, + # so only the votes for the remaining masternodes are actually recorded + many_result = node.gobject("vote-many", proposal_hash, "funding", "yes") + assert_equal(many_result["detail"][self.mninfo[0].proTxHash]["result"], "failed") + for mn in self.mninfo[1:]: + assert_equal(many_result["detail"][mn.proTxHash]["result"], "success") assert_equal(node.gobject("get", proposal_hash)["FundingResult"]["YesCount"], self.mn_count - 1) assert_equal(node.gobject("get", proposal_hash)["FundingResult"]["NoCount"], 1) assert_equal(node.gobject("count")["votes"], self.mn_count) @@ -93,26 +101,47 @@ def test_gobject_wallet_paths(self): self.wait_until(lambda: mn_node.gobject("get", proposal_hash)["FundingResult"]["YesCount"] == self.mn_count - 1, timeout=10) self.wait_until(lambda: mn_node.gobject("get", proposal_hash)["FundingResult"]["NoCount"] == 1, timeout=10) - def test_protx_register_fund(self): + def test_protx_register_fund(self) -> MasternodeInfo: node = self.nodes[0] self.log.info("Test protx register_fund (wallet-funded collateral)") - self.fund_mn = self.prepare_unstarted_mn(len(self.nodes) + 1) - node.sendtoaddress(self.fund_mn.fundsAddr, self.fund_mn.get_collateral_value() + 0.001) + mn = self.prepare_unstarted_mn(len(self.nodes) + 1) + node.sendtoaddress(mn.fundsAddr, mn.get_collateral_value() + 0.001) self.bump_mocktime(1) self.generate(node, 1) - txid = self.fund_mn.register_fund(node, submit=True) + txid = mn.register_fund(node, submit=True) assert txid is not None self.confirm_tx(txid) - vout = self.fund_mn.get_collateral_vout(node, txid) - self.fund_mn.set_params(proTxHash=txid, collateral_txid=txid, collateral_vout=vout) + vout = mn.get_collateral_vout(node, txid) + mn.set_params(proTxHash=txid, collateral_txid=txid, collateral_vout=vout) assert txid in node.protx("list", "registered") - assert_equal(node.protx("info", txid)["collateralAddress"], self.fund_mn.collateral_address) + assert_equal(node.protx("info", txid)["collateralAddress"], mn.collateral_address) assert "%s-%d" % (txid, vout) in node.masternode("list") + return mn + + def test_protx_register_own_collateral(self): + node = self.nodes[0] + self.log.info("Test protx register (collateral owned by the wallet, wallet-signed payload)") + mn = self.prepare_unstarted_mn(len(self.nodes) + 2) + collateral_txid = node.sendtoaddress(mn.collateral_address, mn.get_collateral_value()) + node.sendtoaddress(mn.fundsAddr, 0.001) + self.bump_mocktime(1) + self.generate(node, 1) + collateral_vout = mn.get_collateral_vout(node, collateral_txid) + mn.set_params(collateral_txid=collateral_txid, collateral_vout=collateral_vout) + # Unlike register_prepare, this proves ownership of the collateral by signing the + # payload with the wallet itself (CWallet::SignMessage -> SPKM dispatch) + protx_hash = mn.register(node, submit=True) + assert protx_hash is not None + mn.set_params(proTxHash=protx_hash) + self.confirm_tx(protx_hash) + assert protx_hash in node.protx("list", "registered") + assert_equal(node.protx("info", protx_hash)["collateralHash"], collateral_txid) + assert "%s-%d" % (collateral_txid, collateral_vout) in node.masternode("list") def test_protx_register_external(self): node = self.nodes[0] self.log.info("Test protx register_prepare + signmessage + register_submit (external collateral)") - mn = self.prepare_unstarted_mn(len(self.nodes) + 2) + mn = self.prepare_unstarted_mn(len(self.nodes) + 3) collateral_txid = node.sendtoaddress(mn.collateral_address, mn.get_collateral_value()) node.sendtoaddress(mn.fundsAddr, 0.001) self.bump_mocktime(1) @@ -132,10 +161,9 @@ def test_protx_register_external(self): assert_equal(node.protx("info", protx_hash)["collateralHash"], collateral_txid) assert "%s-%d" % (collateral_txid, collateral_vout) in node.masternode("list") - def test_protx_update_service(self): + def test_protx_update_service(self, mn: MasternodeInfo): node = self.nodes[0] self.log.info("Test protx update_service") - mn = self.fund_mn node.sendtoaddress(mn.fundsAddr, 0.001) self.bump_mocktime(1) self.generate(node, 1) @@ -145,18 +173,16 @@ def test_protx_update_service(self): self.confirm_tx(txid) assert_equal(node.protx("info", mn.proTxHash)["state"]["addresses"]["core_p2p"][0], new_address) - def test_protx_update_registrar(self): + def test_protx_update_registrar(self, mn: MasternodeInfo): node = self.nodes[0] self.log.info("Test protx update_registrar (owner key wallet signing)") - mn = self.fund_mn node.sendtoaddress(mn.fundsAddr, 0.001) self.bump_mocktime(1) self.generate(node, 1) old_state = node.protx("info", mn.proTxHash)["state"] new_voting_address = node.getnewaddress() assert old_state["votingAddress"] != new_voting_address - txid = mn.update_registrar(node, submit=True, pubKeyOperator="", votingAddr=new_voting_address, - rewards_address="", fundsAddr=mn.fundsAddr) + txid = mn.update_registrar(node, submit=True, votingAddr=new_voting_address, fundsAddr=mn.fundsAddr) assert txid is not None self.confirm_tx(txid) new_state = node.protx("info", mn.proTxHash)["state"]