Skip to content

Commit 6280cc0

Browse files
committed
QA: Add tiertwo_dkg_pose functional test
1 parent e993a7a commit 6280cc0

File tree

3 files changed

+322
-1
lines changed

3 files changed

+322
-1
lines changed

test/functional/test_framework/test_framework.py

Lines changed: 166 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,8 @@
6161
set_node_times,
6262
SPORK_ACTIVATION_TIME,
6363
SPORK_DEACTIVATION_TIME,
64-
satoshi_round
64+
satoshi_round,
65+
wait_until,
6566
)
6667

6768
class TestStatus(Enum):
@@ -1336,6 +1337,170 @@ def check_proreg_payload(self, dmn, json_tx):
13361337
assert_equal(pl["operatorPubKey"], dmn.operator_pk)
13371338
assert_equal(pl["payoutAddress"], dmn.payee)
13381339

1340+
# LLMQs
1341+
1342+
def get_quorum_connections(self, node, members):
1343+
conn = []
1344+
for peer in [p for p in node.getpeerinfo() if p["masternode"]]:
1345+
x = [m for m in members if m.proTx == peer["verif_mn_proreg_tx_hash"]]
1346+
if len(x) > 0:
1347+
conn.append(x[0].idx)
1348+
return conn
1349+
1350+
def wait_for_mn_connections(self, members):
1351+
def count_mn_conn(n):
1352+
return len(self.get_quorum_connections(n, members))
1353+
ql = len(members)
1354+
wait_until(lambda: [count_mn_conn(self.nodes[mn.idx]) for mn in members] == [ql - 1] * ql)
1355+
for mn in members:
1356+
conn = self.get_quorum_connections(self.nodes[mn.idx], members)
1357+
self.log.info("Authenticated connections to node %d: %s" % (mn.idx, conn))
1358+
1359+
def wait_for_dkg_phase(self, phase, quorum_hash, quorum_height, members_online, missing_count):
1360+
assert_greater_than(missing_count, -1)
1361+
online_count = len(members_online)
1362+
1363+
def check_phase():
1364+
status = [self.nodes[mn.idx].quorumdkgstatus()["session"]
1365+
for mn in members_online]
1366+
for s in status:
1367+
if "llmq_test" not in s:
1368+
return False
1369+
s = s["llmq_test"]
1370+
if (s["quorumHash"] != quorum_hash
1371+
or s["quorumHeight"] != quorum_height
1372+
or s["phase"] != phase
1373+
or s["sentContributions"] != (phase != 1)
1374+
or s["sentComplaint"] != (phase > 2 and missing_count > 0)
1375+
or s["sentJustification"]
1376+
or s["sentPrematureCommitment"] != (phase > 4)
1377+
or s["receivedContributions"] != (0 if phase == 1 else online_count)
1378+
or s["receivedComplaints"] != (0 if phase <= 2 else (online_count * missing_count))
1379+
or s["receivedJustifications"] != 0
1380+
or s["receivedPrematureCommitments"] != (0 if phase <= 4 else online_count)):
1381+
return False
1382+
return True
1383+
1384+
timeout = time.time() + 60
1385+
while time.time() < timeout:
1386+
if check_phase():
1387+
return # all good
1388+
time.sleep(6)
1389+
1390+
# Timeout: print the cause
1391+
self.log.error("Cannot reach phase %d" % phase)
1392+
for mo in members_online:
1393+
self.log.error("Checking node %d..." % mo.idx)
1394+
conn = self.get_quorum_connections(self.nodes[mo.idx], members_online)
1395+
self.log.error("Connected to: %s" % str(conn))
1396+
fs = self.nodes[mo.idx].quorumdkgstatus()["session"]
1397+
assert "llmq_test" in fs
1398+
fs = fs["llmq_test"]
1399+
assert_equal(fs["quorumHash"], quorum_hash)
1400+
assert_equal(fs["quorumHeight"], quorum_height)
1401+
assert_equal(fs["phase"], phase)
1402+
assert_equal(fs["sentContributions"], (phase != 1))
1403+
assert_equal(fs["sentComplaint"], (phase > 2 and missing_count > 0))
1404+
assert_equal(fs["sentJustification"], False)
1405+
assert_equal(fs["sentPrematureCommitment"], (phase > 4))
1406+
assert_equal(fs["receivedContributions"], (0 if phase == 1 else online_count))
1407+
assert_equal(fs["receivedComplaints"], (0 if phase <= 2 else (online_count * missing_count)))
1408+
assert_equal(fs["receivedJustifications"], 0)
1409+
assert_equal(fs["receivedPrematureCommitments"], (0 if phase <= 4 else online_count))
1410+
1411+
def get_quorum_members(self, quorum_hash):
1412+
members = []
1413+
# preserve getquorummembers order
1414+
for protx in self.nodes[self.minerPos].getquorummembers(100, quorum_hash):
1415+
members.append(next(mn for mn in self.mns if mn.proTx == protx))
1416+
return members
1417+
1418+
def check_final_commitment(self, qfc, valid, signers):
1419+
signersCount = 0
1420+
signersBitStr = 0
1421+
for i, s in enumerate(signers):
1422+
signersCount += s
1423+
signersBitStr += (s << i)
1424+
signersBitStr = "0%d" % signersBitStr
1425+
validCount = 0
1426+
validBitStr = 0
1427+
for i, s in enumerate(valid):
1428+
validCount += s
1429+
validBitStr += (s << i)
1430+
validBitStr = "0%d" % validBitStr
1431+
assert_equal(qfc['version'], 1)
1432+
assert_equal(qfc['llmqType'], 100)
1433+
assert_equal(qfc['signersCount'], signersCount)
1434+
assert_equal(qfc['signers'], signersBitStr)
1435+
assert_equal(qfc['validMembersCount'], validCount)
1436+
assert_equal(qfc['validMembers'], validBitStr)
1437+
1438+
"""
1439+
Returns pair (qfc, bad_member)
1440+
qfc : quorum final commitment json object
1441+
bad_member : Masternode object for non participating node
1442+
(or None if all members participated)
1443+
"""
1444+
def mine_quorum(self, invalidate_func=None, invalidated_idx=None, skip_bad_member_sync=False):
1445+
nodes_to_sync = self.nodes.copy()
1446+
if invalidated_idx:
1447+
assert invalidate_func is None
1448+
if skip_bad_member_sync:
1449+
nodes_to_sync.pop(invalidated_idx)
1450+
miner = self.nodes[self.minerPos]
1451+
session_blocks = miner.getblockcount() % 20
1452+
if session_blocks != 0:
1453+
miner.generate(20 - session_blocks)
1454+
self.sync_blocks(nodes_to_sync)
1455+
quorum_height = miner.getblockcount()
1456+
self.log.info("New DKG starts at block %d..." % quorum_height)
1457+
quorum_hash = miner.getblockhash(quorum_height)
1458+
members = self.get_quorum_members(quorum_hash)
1459+
self.log.info("members: %s" % str([m.idx for m in members]))
1460+
bad_member = None
1461+
mvalid = msigners = [1, 1, 1]
1462+
if invalidate_func is not None:
1463+
assert invalidated_idx is None
1464+
bad_member = members.pop()
1465+
invalidate_func(self.nodes[bad_member.idx])
1466+
mvalid[2] = msigners[2] = 0
1467+
if skip_bad_member_sync:
1468+
nodes_to_sync.pop(bad_member.idx)
1469+
elif invalidated_idx in [m.idx for m in members]:
1470+
bad_member = next(m for m in members if m.idx == invalidated_idx)
1471+
j = members.index(bad_member)
1472+
mvalid[j] = msigners[j] = 0
1473+
members.remove(bad_member)
1474+
if bad_member is not None:
1475+
self.log.info("Removed node %d" % bad_member.idx)
1476+
assert_equal(len(members), 3 if bad_member is None else 2)
1477+
self.wait_for_mn_connections(members)
1478+
phase_string = [
1479+
"1 (Initialization)",
1480+
"2 (Contribution)",
1481+
"3 (Complaining)",
1482+
"4 (Justification)",
1483+
"5 (Commitment/Finalization)",
1484+
"6 (Mining)"
1485+
]
1486+
for phase in range(1, 7):
1487+
self.log.info("Phase %s - block %d" % (phase_string[phase-1], miner.getblockcount()))
1488+
self.wait_for_dkg_phase(phase,
1489+
quorum_hash,
1490+
quorum_height,
1491+
members,
1492+
missing_count=(0 if bad_member is None else 1))
1493+
miner.generate(2 if phase != 6 else 1)
1494+
self.sync_all(nodes_to_sync)
1495+
time.sleep(1 if phase != 5 else 2) # sleep a bit more during finalization
1496+
assert_equal(quorum_height + 11, miner.getblockcount())
1497+
qfc = miner.getminedcommitment(100, quorum_hash)
1498+
self.check_final_commitment(qfc, valid=mvalid, signers=msigners)
1499+
comm_height = miner.getblock(qfc['block_hash'], True)['height']
1500+
assert_equal(comm_height, quorum_height + 11)
1501+
self.log.info("Final commitment correctly mined on chain")
1502+
return qfc, bad_member
1503+
13391504

13401505
# ------------------------------------------------------
13411506

test/functional/test_runner.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -164,6 +164,7 @@
164164
'tiertwo_masternode_ping.py', # ~ 293 sec
165165
'tiertwo_reorg_mempool.py', # ~ 97 sec
166166
'tiertwo_governance_invalid_budget.py',
167+
'tiertwo_dkg_pose.py',
167168
]
168169

169170
SAPLING_SCRIPTS = [
Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,155 @@
1+
#!/usr/bin/env python3
2+
# Copyright (c) 2021 The PIVX Core developers
3+
# Distributed under the MIT software license, see the accompanying
4+
# file COPYING or http://www.opensource.org/licenses/mit-license.php.
5+
"""Test deterministic masternodes"""
6+
7+
import time
8+
9+
from test_framework.test_framework import PivxTestFramework
10+
from test_framework.util import (
11+
assert_equal,
12+
connect_nodes_clique,
13+
wait_until,
14+
)
15+
16+
17+
class DkgPoseTest(PivxTestFramework):
18+
19+
def set_test_params(self):
20+
# 1 miner, 1 controller, 6 remote mns
21+
self.num_nodes = 8
22+
self.minerPos = 0
23+
self.controllerPos = 1
24+
self.setup_clean_chain = True
25+
self.extra_args = [["-nuparams=v5_shield:1", "-nuparams=v6_evo:130", "-debug=llmq", "-debug=dkg", "-debug=net"]] * self.num_nodes
26+
self.extra_args[0].append("-sporkkey=932HEevBSujW2ud7RfB1YF91AFygbBRQj3de3LyaCRqNzKKgWXi")
27+
28+
def add_new_dmn(self, strType, op_keys=None, from_out=None):
29+
self.mns.append(self.register_new_dmn(2 + len(self.mns),
30+
self.minerPos,
31+
self.controllerPos,
32+
strType,
33+
outpoint=from_out,
34+
op_blskeys=op_keys))
35+
36+
def check_mn_list(self):
37+
for i in range(self.num_nodes):
38+
self.check_mn_list_on_node(i, self.mns)
39+
self.log.info("Deterministic list contains %d masternodes for all peers." % len(self.mns))
40+
41+
def check_mn_enabled_count(self, enabled, total):
42+
for node in self.nodes:
43+
node_count = node.getmasternodecount()
44+
assert_equal(node_count['enabled'], enabled)
45+
assert_equal(node_count['total'], total)
46+
47+
def wait_until_mnsync_completed(self):
48+
SYNC_FINISHED = [999] * self.num_nodes
49+
synced = [-1] * self.num_nodes
50+
timeout = time.time() + 120
51+
while synced != SYNC_FINISHED and time.time() < timeout:
52+
synced = [node.mnsync("status")["RequestedMasternodeAssets"]
53+
for node in self.nodes]
54+
if synced != SYNC_FINISHED:
55+
time.sleep(5)
56+
if synced != SYNC_FINISHED:
57+
raise AssertionError("Unable to complete mnsync: %s" % str(synced))
58+
59+
def disable_network_for_node(self, node):
60+
node.setnetworkactive(False)
61+
wait_until(lambda: node.getconnectioncount() == 0)
62+
63+
def setup_test(self):
64+
self.mns = []
65+
self.disable_mocktime()
66+
connect_nodes_clique(self.nodes)
67+
68+
# Enforce mn payments and reject legacy mns at block 131
69+
self.activate_spork(0, "SPORK_8_MASTERNODE_PAYMENT_ENFORCEMENT")
70+
assert_equal("success", self.set_spork(self.minerPos, "SPORK_21_LEGACY_MNS_MAX_HEIGHT", 130))
71+
time.sleep(1)
72+
assert_equal([130] * self.num_nodes, [self.get_spork(x, "SPORK_21_LEGACY_MNS_MAX_HEIGHT")
73+
for x in range(self.num_nodes)])
74+
75+
# Mine 130 blocks
76+
self.log.info("Mining...")
77+
self.nodes[self.minerPos].generate(10)
78+
self.sync_blocks()
79+
self.wait_until_mnsync_completed()
80+
self.nodes[self.minerPos].generate(120)
81+
self.sync_blocks()
82+
self.assert_equal_for_all(130, "getblockcount")
83+
84+
# enabled/total masternodes: 0/0
85+
self.check_mn_enabled_count(0, 0)
86+
87+
# Create 6 DMNs and init the remote nodes
88+
self.log.info("Initializing masternodes...")
89+
for _ in range(2):
90+
self.add_new_dmn("internal")
91+
self.add_new_dmn("external")
92+
self.add_new_dmn("fund")
93+
assert_equal(len(self.mns), 6)
94+
for mn in self.mns:
95+
self.nodes[mn.idx].initmasternode(mn.operator_sk, "", True)
96+
time.sleep(1)
97+
self.nodes[self.minerPos].generate(1)
98+
self.sync_blocks()
99+
100+
# enabled/total masternodes: 6/6
101+
self.check_mn_enabled_count(6, 6)
102+
self.check_mn_list()
103+
104+
# Check status from remote nodes
105+
assert_equal([self.nodes[idx].getmasternodestatus()['status'] for idx in range(2, self.num_nodes)],
106+
["Ready"] * (self.num_nodes - 2))
107+
self.log.info("All masternodes ready.")
108+
109+
110+
def run_test(self):
111+
miner = self.nodes[self.minerPos]
112+
113+
# initialize and start masternodes
114+
self.setup_test()
115+
assert_equal(len(self.mns), 6)
116+
117+
# Mine a LLMQ final commitment regularly with 3 signers
118+
_, bad_mnode = self.mine_quorum()
119+
assert_equal(171, miner.getblockcount())
120+
assert bad_mnode is None
121+
122+
# Mine the next final commitment after disconnecting a member
123+
_, bad_mnode = self.mine_quorum(invalidate_func=self.disable_network_for_node,
124+
skip_bad_member_sync=True)
125+
assert_equal(191, miner.getblockcount())
126+
assert bad_mnode is not None
127+
128+
# Check PoSe
129+
self.log.info("Check that node %d has been PoSe punished..." % bad_mnode.idx)
130+
expected_penalty = 66
131+
assert_equal(expected_penalty, miner.listmasternodes(bad_mnode.proTx)[0]["dmnstate"]["PoSePenalty"])
132+
self.log.info("Node punished.")
133+
# penalty decreases at every block
134+
miner.generate(1)
135+
self.sync_all([n for i, n in enumerate(self.nodes) if i != bad_mnode.idx])
136+
expected_penalty -= 1
137+
assert_equal(expected_penalty, miner.listmasternodes(bad_mnode.proTx)[0]["dmnstate"]["PoSePenalty"])
138+
139+
# Keep mining commitments until the bad node is banned
140+
timeout = time.time() + 600
141+
while time.time() < timeout:
142+
self.mine_quorum(invalidated_idx=bad_mnode.idx, skip_bad_member_sync=True)
143+
pose_height = miner.listmasternodes(bad_mnode.proTx)[0]["dmnstate"]["PoSeBanHeight"]
144+
if pose_height != -1:
145+
self.log.info("Node %d has been PoSeBanned at height %d" % (bad_mnode.idx, pose_height))
146+
self.log.info("All good.")
147+
return
148+
time.sleep(1)
149+
# timeout
150+
raise Exception("Node not banned after 10 minutes")
151+
152+
153+
154+
if __name__ == '__main__':
155+
DkgPoseTest().main()

0 commit comments

Comments
 (0)