diff --git a/test/functional/feature_llmq_signing.py b/test/functional/feature_llmq_signing.py index 61db6c20942d..8511446ef496 100755 --- a/test/functional/feature_llmq_signing.py +++ b/test/functional/feature_llmq_signing.py @@ -27,22 +27,19 @@ 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_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) - 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 +74,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 +143,80 @@ 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.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.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() + + # 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") + 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/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 5d5399043579..0f129b6526c6 100755 --- a/test/functional/test_runner.py +++ b/test/functional/test_runner.py @@ -116,12 +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 --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 - 'feature_llmq_signing.py --spork21', # NOTE: needs dash_hash to pass + 'feature_llmq_simplepose.py --disable-spork23', # NOTE: needs dash_hash to pass 'feature_llmq_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 @@ -131,9 +129,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', @@ -146,6 +142,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..ecf72572985a --- /dev/null +++ b/test/functional/wallet_dash_rpcs.py @@ -0,0 +1,194 @@ +#!/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 (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 + +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 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]) + 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() + funded_mn = self.test_protx_register_fund() + self.test_protx_register_own_collateral() + self.test_protx_register_external() + self.test_protx_update_service(funded_mn) + self.test_protx_update_registrar(funded_mn) + + 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)") + 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) + + 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) -> MasternodeInfo: + node = self.nodes[0] + self.log.info("Test protx register_fund (wallet-funded collateral)") + 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 = mn.register_fund(node, submit=True) + assert txid is not None + self.confirm_tx(txid) + 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"], 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) + 3) + 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, mn: MasternodeInfo): + node = self.nodes[0] + self.log.info("Test protx update_service") + 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, mn: MasternodeInfo): + node = self.nodes[0] + self.log.info("Test protx update_registrar (owner key wallet signing)") + 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, votingAddr=new_voting_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()