Skip to content

Commit ab21307

Browse files
authored
Merge pull request #8091 from onflow/tim/7851-bootstrap-clustering-test
Collection cluster bootstrapping+voting test
2 parents 8cfb923 + b92e713 commit ab21307

File tree

11 files changed

+227
-53
lines changed

11 files changed

+227
-53
lines changed

cmd/bootstrap/cmd/clustering.go

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -120,7 +120,7 @@ func clusterAssignment(cmd *cobra.Command, args []string) {
120120
}
121121

122122
log.Info().Msg("computing collection node clusters")
123-
assignments, clusters, err := common.ConstructClusterAssignment(log, partnerList, internalList, int(flagCollectionClusters), clusteringPrg)
123+
assignments, clusters, canConstructQCs, err := common.ConstructClusterAssignment(log, partnerList, internalList, int(flagCollectionClusters), clusteringPrg)
124124
if err != nil {
125125
log.Fatal().Err(err).Msg("unable to generate cluster assignment")
126126
}
@@ -145,6 +145,12 @@ func clusterAssignment(cmd *cobra.Command, args []string) {
145145
model.FilterByRole(internalNodes, flow.RoleCollection),
146146
)
147147
log.Info().Msg("")
148+
149+
if canConstructQCs {
150+
log.Info().Msg("enough votes for collection clusters are present - bootstrapping can continue with root block creation")
151+
} else {
152+
log.Info().Msg("not enough internal votes to generate cluster QCs, need partner votes before root block creation")
153+
}
148154
}
149155

150156
// constructClusterRootVotes generates and writes vote files for internal collector nodes with private keys available.

cmd/bootstrap/cmd/constraints.go

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,8 +26,7 @@ func ensureUniformNodeWeightsPerRole(allNodes flow.IdentityList) {
2626
}
2727
}
2828

29-
// Checks constraints about the number of partner and internal nodes.
30-
// - Internal nodes must comprise >2/3 of each collector cluster.
29+
// Checks constraints about the weights of partner and internal nodes.
3130
// - for all roles R:
3231
// all node with role R must have the same weight
3332
func checkConstraints(partnerNodes, internalNodes []model.NodeInfo) {

cmd/bootstrap/cmd/finalize_test.go

Lines changed: 8 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,7 @@ func TestClusterAssignment(t *testing.T) {
122122
// Happy path (limit set-up, can't have one less internal node)
123123
partnersLen := 7
124124
internalLen := 22
125+
// clusters are assigned with ratios (partner:internal) [2:5, 2:5, 1:4, 1:4, 1:4]
125126
partners := unittest.NodeInfosFixture(partnersLen, unittest.WithRole(flow.RoleCollection))
126127
internals := unittest.NodeInfosFixture(internalLen, unittest.WithRole(flow.RoleCollection))
127128

@@ -134,15 +135,16 @@ func TestClusterAssignment(t *testing.T) {
134135

135136
log := zerolog.Nop()
136137
// should not error
137-
_, clusters, err := common.ConstructClusterAssignment(log, model.ToIdentityList(partners), model.ToIdentityList(internals), int(flagCollectionClusters), prng)
138+
_, _, canConstructQCs, err := common.ConstructClusterAssignment(log, model.ToIdentityList(partners), model.ToIdentityList(internals), int(flagCollectionClusters), prng)
138139
require.NoError(t, err)
139-
require.True(t, checkClusterConstraint(clusters, partners, internals))
140+
require.True(t, canConstructQCs)
140141

141142
// unhappy Path
142-
internals = internals[:21] // reduce one internal node
143-
// should error
144-
_, _, err = common.ConstructClusterAssignment(log, model.ToIdentityList(partners), model.ToIdentityList(internals), int(flagCollectionClusters), prng)
145-
require.Error(t, err)
143+
internals = internals[:len(internals)-1] // reduce one internal node
144+
// should no longer be able to construct QCs using only votes from internal nodes
145+
_, _, canConstructQCs, err = common.ConstructClusterAssignment(log, model.ToIdentityList(partners), model.ToIdentityList(internals), int(flagCollectionClusters), prng)
146+
require.NoError(t, err)
147+
require.False(t, canConstructQCs)
146148
// revert the flag value
147149
flagCollectionClusters = tmp
148150
}
@@ -190,28 +192,6 @@ func TestEpochTimingConfig(t *testing.T) {
190192
})
191193
}
192194

193-
// Check about the number of internal/partner nodes in each cluster. The identites
194-
// in each cluster do not matter for this check.
195-
func checkClusterConstraint(clusters flow.ClusterList, partnersInfo []model.NodeInfo, internalsInfo []model.NodeInfo) bool {
196-
partners := model.ToIdentityList(partnersInfo)
197-
internals := model.ToIdentityList(internalsInfo)
198-
for _, cluster := range clusters {
199-
var clusterPartnerCount, clusterInternalCount int
200-
for _, node := range cluster {
201-
if _, exists := partners.ByNodeID(node.NodeID); exists {
202-
clusterPartnerCount++
203-
}
204-
if _, exists := internals.ByNodeID(node.NodeID); exists {
205-
clusterInternalCount++
206-
}
207-
}
208-
if clusterInternalCount <= clusterPartnerCount*2 {
209-
return false
210-
}
211-
}
212-
return true
213-
}
214-
215195
func TestMergeNodeInfos(t *testing.T) {
216196
partnersLen := 7
217197
internalLen := 22

cmd/bootstrap/cmd/rootblock.go

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -228,10 +228,6 @@ func rootBlock(cmd *cobra.Command, args []string) {
228228
log.Fatal().Err(err).Msgf("failed to merge node infos")
229229
}
230230

231-
log.Info().Msg("running DKG for consensus nodes")
232-
randomBeaconData, dkgIndexMap := runBeaconKG(model.FilterByRole(stakingNodes, flow.RoleConsensus))
233-
log.Info().Msg("")
234-
235231
// create flow.IdentityList representation of the participant set
236232
participants := model.ToIdentityList(stakingNodes).Sort(flow.Canonical[flow.Identity])
237233

@@ -253,6 +249,10 @@ func rootBlock(cmd *cobra.Command, args []string) {
253249
clusterQCs := run.ConstructClusterRootQCsFromVotes(log, clusters, internalNodes, clusterBlocks, votes)
254250
log.Info().Msg("")
255251

252+
log.Info().Msg("running DKG for consensus nodes")
253+
randomBeaconData, dkgIndexMap := runBeaconKG(model.FilterByRole(stakingNodes, flow.RoleConsensus))
254+
log.Info().Msg("")
255+
256256
log.Info().Msg("constructing root header")
257257
headerBody, err := constructRootHeaderBody(flagRootChain, flagRootParent, flagRootHeight, flagRootView, flagRootTimestamp)
258258
if err != nil {

cmd/bootstrap/cmd/rootblock_test.go

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -28,18 +28,18 @@ const rootBlockHappyPathLogs = "collecting partner network and staking keys" +
2828
`removed 0 internal partner nodes` +
2929
`checking constraints on consensus nodes` +
3030
`assembling network and staking keys` +
31-
`running DKG for consensus nodes` +
32-
`read \d+ node infos for DKG` +
33-
`will run DKG` +
34-
`finished running DKG` +
35-
`.+/random-beacon.priv.json` +
36-
`wrote file \S+/root-dkg-data.priv.json` +
3731
`reading votes for collection node cluster root blocks` +
3832
`read vote .+` +
3933
`constructing root blocks for collection node clusters` +
4034
`constructing root QCs for collection node clusters` +
4135
`producing QC for cluster .*` +
4236
`producing QC for cluster .*` +
37+
`running DKG for consensus nodes` +
38+
`read \d+ node infos for DKG` +
39+
`will run DKG` +
40+
`finished running DKG` +
41+
`.+/random-beacon.priv.json` +
42+
`wrote file \S+/root-dkg-data.priv.json` +
4343
`constructing root header` +
4444
`constructing intermediary bootstrapping data` +
4545
`wrote file \S+/intermediary-bootstrapping-data.json` +

cmd/bootstrap/run/epochs.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -181,7 +181,7 @@ func GenerateRecoverTxArgsWithDKG(
181181
return nil, fmt.Errorf("could not initialize PRNG: %w", err)
182182
}
183183
log.Info().Msgf("partitioning %d partners + %d internal nodes into %d collector clusters", len(partnerCollectors), len(internalCollectors), collectionClusters)
184-
assignments, clusters, err := common.ConstructClusterAssignment(log, partnerCollectors, internalCollectors, collectionClusters, rng)
184+
assignments, clusters, _, err := common.ConstructClusterAssignment(log, partnerCollectors, internalCollectors, collectionClusters, rng)
185185
if err != nil {
186186
return nil, fmt.Errorf("unable to generate cluster assignment: %w", err)
187187
}

cmd/bootstrap/test/.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
*
2+
!permissionless-cluster-qc-voting-transit-test.sh
3+
!.gitignore
Lines changed: 187 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,187 @@
1+
#!/usr/bin/env bash
2+
3+
# This file implements a test for bootstrapping a network while having <2/3rds of private keys available for Collector nodes.
4+
# In particular, it tests decentralized voting by Collection nodes on root cluster blocks as part of the bootstrapping process.
5+
# The test can be run using either local or GCP bucket vote transport. To test GCP bucket transport, set the `bucket` and `token` variables below.
6+
# To run this test, you must have `jq` and `gsutil` installed.
7+
8+
bucket=
9+
token=
10+
11+
bootstrapcmd="go run .."
12+
transitcmd="go run ../transit"
13+
14+
clusteringpath="public-root-information/root-clustering.json"
15+
clustervotespath="public-root-information/root-block-votes"
16+
# partner dir must end with `public-root-information`, otherwise partner nodeinfo will not be read from it
17+
partner_dir="./public-root-information"
18+
# keygen dir must not exist yet, or be empty
19+
keygen_dir="./keygen"
20+
21+
clusterCount=2
22+
23+
# exit early if anything fails
24+
set -e
25+
# avoid overwriting existing files or using data from a previous run
26+
if [ "$(ls | wc -l)" -gt 1 ]
27+
then
28+
echo "Found files in $(pwd), please clean up after previous runs:"
29+
echo "rm -rf permissionless public-root-information private-root-information execution-state $keygen_dir $partner_dir node-config.json partner-weights.json"
30+
exit 1
31+
fi
32+
33+
$bootstrapcmd genconfig --address-format "%s%d.example.com:3569" \
34+
--access 2 --collection 7 --consensus 3 --execution 2 --verification 1 --weight 100 \
35+
-o ./ --config ./node-config.json
36+
$bootstrapcmd keygen --config ./node-config.json -o "$keygen_dir"
37+
38+
39+
echo "simulating permissionless nodes"
40+
# mark >33% of collectors permissionless / non-internal
41+
permissionless_collectors=$(jq -r 'map(select(.["Role"]=="collection")) | .[:(length/3|floor)+1] | .[] | .["NodeID"]' \
42+
-- "$keygen_dir/public-root-information/node-internal-infos.pub.json")
43+
44+
# generate partner-weights file
45+
jq 'map({(.["NodeID"]):.["Weight"]}) | add' \
46+
-- "$keygen_dir/public-root-information/node-internal-infos.pub.json" > ./partner-weights.json
47+
48+
# generate partner-node-infos (for non-internal nodes only)
49+
mkdir -p "$partner_dir"
50+
for node in $permissionless_collectors
51+
do
52+
jq --arg jq_node_id "$node" '.[] | select(.["NodeID"]==$jq_node_id)' \
53+
-- "$keygen_dir/public-root-information/node-internal-infos.pub.json" \
54+
> "$partner_dir/node-info.pub.$node.json"
55+
done
56+
57+
# create a directory for each permissionless node to store its private information
58+
for node in $permissionless_collectors
59+
do
60+
mkdir -p "./permissionless/$node/private-root-information"
61+
mkdir -p "./permissionless/$node/public-root-information"
62+
echo "$node" >> "./permissionless/$node/public-root-information/node-id"
63+
mv "$keygen_dir/private-root-information/private-node-info_$node" "./permissionless/$node/private-root-information/"
64+
done
65+
66+
67+
$bootstrapcmd cluster-assignment \
68+
--epoch-counter 0 \
69+
--collection-clusters $clusterCount \
70+
--clustering-random-seed 00000000000000000000000000000000000000000000000000000000deadbeef \
71+
--config ./node-config.json \
72+
-o ./ \
73+
--partner-dir "$partner_dir" \
74+
--partner-weights ./partner-weights.json \
75+
--internal-priv-dir "$keygen_dir/private-root-information"
76+
77+
78+
#confirm that the bootstrapping process cannot continue yet (not enough votes for cluster QCs)
79+
echo "Expecting FTL (not enough votes for Cluster QCs)..."
80+
$bootstrapcmd rootblock \
81+
--root-chain bench \
82+
--root-height 0 \
83+
--root-parent 0000000000000000000000000000000000000000000000000000000000000000 \
84+
--root-view 0 \
85+
--epoch-counter 0 \
86+
--epoch-length 30000 \
87+
--epoch-staking-phase-length 20000 \
88+
--epoch-dkg-phase-length 2000 \
89+
--random-seed 00000000000000000000000000000000000000000000000000000000deadbeef \
90+
--collection-clusters $clusterCount \
91+
--use-default-epoch-timing \
92+
--kvstore-finalization-safety-threshold=1000 \
93+
--kvstore-epoch-extension-view-count=2000 \
94+
--config ./node-config.json \
95+
-o ./ \
96+
--partner-dir "$partner_dir" \
97+
--partner-weights ./partner-weights.json \
98+
--internal-priv-dir "$keygen_dir/private-root-information" \
99+
--intermediary-clustering-data "./$clusteringpath" \
100+
--cluster-votes-dir "./$clustervotespath" \
101+
| grep "not enough votes to create qc"
102+
103+
echo "Collecting votes for Cluster QCs..."
104+
# permissionless collectors retrieve the clustering data
105+
if [ -n "$bucket" ]
106+
then
107+
# upload to cloud bucket; collectors pull using transit script
108+
echo "uploading to cloud bucket..."
109+
gsutil cp "./$clusteringpath" "gs://$bucket/$token/$clusteringpath"
110+
for node in $permissionless_collectors
111+
do
112+
$transitcmd pull-clustering -g "$bucket" -t "$token" -b "./permissionless/$node"
113+
done
114+
else
115+
# copy locally
116+
for node in $permissionless_collectors
117+
do
118+
cp "./$clusteringpath" "./permissionless/$node/$clusteringpath"
119+
done
120+
fi
121+
122+
# cluster voting
123+
for node in $permissionless_collectors
124+
do
125+
$transitcmd generate-cluster-block-vote -b "./permissionless/$node"
126+
done
127+
128+
# collectors push votes, and bootstrapping machine retrieves them
129+
if [ -n "$bucket" ]
130+
then
131+
# collectors push using transit script
132+
for node in $permissionless_collectors
133+
do
134+
$transitcmd push-cluster-block-vote -g "$bucket" -t "$token" -b "./permissionless/$node"
135+
done
136+
gsutil cp "gs://$bucket/$token/root-cluster-block-vote.*" "./$clustervotespath/"
137+
else
138+
# copy locally
139+
for node in $permissionless_collectors
140+
do
141+
cp "./permissionless/$node/private-root-information/private-node-info_$node/root-cluster-block-vote.json" "./$clustervotespath/root-cluster-block-vote.$node.json"
142+
done
143+
fi
144+
145+
146+
# root block creation should succeed now that we have enough votes
147+
$bootstrapcmd rootblock \
148+
--root-chain bench \
149+
--root-height 0 \
150+
--root-parent 0000000000000000000000000000000000000000000000000000000000000000 \
151+
--root-view 0 \
152+
--epoch-counter 0 \
153+
--epoch-length 30000 \
154+
--epoch-staking-phase-length 20000 \
155+
--epoch-dkg-phase-length 2000 \
156+
--random-seed 00000000000000000000000000000000000000000000000000000000deadbeef \
157+
--collection-clusters $clusterCount \
158+
--use-default-epoch-timing \
159+
--kvstore-finalization-safety-threshold=1000 \
160+
--kvstore-epoch-extension-view-count=2000 \
161+
--config ./node-config.json \
162+
-o ./ \
163+
--partner-dir "$partner_dir" \
164+
--partner-weights ./partner-weights.json \
165+
--internal-priv-dir "$keygen_dir/private-root-information" \
166+
--intermediary-clustering-data "./$clusteringpath" \
167+
--cluster-votes-dir "./$clustervotespath"
168+
169+
170+
# root block finalization should succeed with enough consensus votes
171+
$bootstrapcmd finalize \
172+
--config ./node-config.json \
173+
--partner-dir "./$partner_dir" \
174+
--partner-weights ./partner-weights.json \
175+
--internal-priv-dir "$keygen_dir/private-root-information" \
176+
--dkg-data ./private-root-information/root-dkg-data.priv.json \
177+
--root-block ./public-root-information/root-block.json \
178+
--intermediary-bootstrapping-data ./public-root-information/intermediary-bootstrapping-data.json \
179+
--root-block-votes-dir ./public-root-information/root-block-votes/ \
180+
--root-commit 0000000000000000000000000000000000000000000000000000000000000000 \
181+
--genesis-token-supply="1000000000.0" \
182+
--service-account-public-key-json "{\"PublicKey\":\"R7MTEDdLclRLrj2MI1hcp4ucgRTpR15PCHAWLM5nks6Y3H7+PGkfZTP2di2jbITooWO4DD1yqaBSAVK8iQ6i0A==\",\"SignAlgo\":2,\"HashAlgo\":1,\"SeqNumber\":0,\"Weight\":1000}" \
183+
-o ./
184+
185+
# allow output to be inspected if necessary
186+
echo "To clean up results, run:"
187+
echo "rm -rf permissionless public-root-information private-root-information execution-state $keygen_dir $partner_dir node-config.json partner-weights.json"

cmd/bootstrap/transit/cmd/generate_cluster_block_vote.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ func addGenerateClusterVoteCmdFlags() {
3434
}
3535

3636
func generateClusterVote(c *cobra.Command, args []string) {
37-
log.Info().Msg("generating root block vote")
37+
log.Info().Msg("generating cluster block vote")
3838

3939
nodeIDString, err := readNodeID()
4040
if err != nil {

cmd/bootstrap/utils/unittest.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ func RunWithSporkBootstrapDir(t testing.TB, f func(bootDir, partnerDir, partnerW
1313
dir := unittest.TempDir(t)
1414
defer os.RemoveAll(dir)
1515

16-
// make sure constraints are satisfied, 2/3's of con and col nodes are internal
16+
// make sure constraints are satisfied to create QCs locally: >2/3's of con and col nodes are internal
1717
internalNodes := GenerateNodeInfos(3, 6, 2, 1, 1)
1818
partnerNodes := GenerateNodeInfos(1, 1, 1, 1, 1)
1919

0 commit comments

Comments
 (0)