Smart Contract

FlowClusterQC

A.8624b52f9ddcd04a.FlowClusterQC

Deployed

1d ago
Feb 26, 2026, 10:31:17 PM UTC

Dependents

0 imports
1
2/* 
3*
4*  Manages the process of collecting votes for the root quorum certificate of the upcoming
5*  epoch for all collection node clusters assigned for the upcoming epoch.
6*
7*  When collector nodes are first registered, they can request a Voter object from this contract.
8*  They'll use this object for every subsequent epoch where they are a staked collector node.
9*
10*  At the beginning of each EpochSetup phase, the admin initializes this contract with
11*  the collector clusters for the upcoming epoch. Each collector node has a single vote
12*  that is allocated for them and they can only call their `vote` function once.
13*  
14*  Once all the clusters have received enough identical votes to surpass their weight threshold,
15*  The QC generation phase is finished and the admin will end the voting.
16*  At any point, anyone can query the voting information for the clusters 
17*  by using the `getClusters` function.
18* 
19*  This contract is a member of a series of epoch smart contracts which coordinates the 
20*  process of transitioning between epochs in Flow.
21*/
22
23import Crypto
24
25access(all) contract FlowClusterQC {
26
27    // ================================================================================
28    // CONTRACT VARIABLES
29    // ================================================================================
30
31    /// Indicates whether votes are currently being collected.
32    /// If false, no node operator will be able to submit votes
33    access(all) var inProgress: Bool
34
35    /// The collection node clusters for the current epoch
36    access(account) var clusters: [Cluster]
37
38    /// Indicates if a voter resource has already been claimed by a node ID
39    /// from the identity table contract
40    /// Node IDs have to claim a voter once
41    /// one node will use the same specific ID and Voter resource for all time
42    /// `nil` means that there is no voting capability for the node ID
43    /// false means that a voter capability for the ID, but it hasn't been claimed
44    /// true means that the voter capability has been claimed by the node
45    access(account) var voterClaimed: {String: Bool}
46
47    /// Indicates what cluster a node is in for the current epoch
48    /// Value is a cluster index
49    access(contract) var nodeCluster: {String: UInt16}
50
51    // ================================================================================
52    // CONTRACT CONSTANTS
53    // ================================================================================
54
55    /// Canonical paths for admin and voter resources
56    access(all) let AdminStoragePath: StoragePath
57    access(all) let VoterStoragePath: StoragePath
58
59    /// Represents a collection node cluster for a given epoch. 
60    access(all) struct Cluster {
61
62        /// The index of the cluster within the cluster array. This uniquely identifies
63        /// a cluster for a given epoch
64        access(all) let index: UInt16
65
66        /// Weights for each nodeID in the cluster
67        access(all) let nodeWeights: {String: UInt64}
68
69        /// The total node weight of all the nodes in the cluster
70        access(all) let totalWeight: UInt64
71
72        /// Votes that nodes claim at the beginning of each EpochSetup phase
73        /// Key is node ID from the identity table contract
74        /// Vote resources without signatures or messages for each node are stored here
75        /// at the beginning of each epoch setup phase. 
76        /// When a node submits a vote, the vote function takes it out of this map,
77        /// adds their signature and message, then adds it back to this vote list.
78        /// If a node has voted, their `signature` and `message` field will be non-`nil`
79        /// If a node hasn't voted, their `signature` and `message` field will be `nil`
80        access(all) var generatedVotes: {String: Vote}
81
82        /// Tracks each unique vote and how much combined weight has been sent for the vote
83        access(all) var uniqueVoteMessageTotalWeights: {String: UInt64}
84
85        init(index: UInt16, nodeWeights: {String: UInt64}) {
86            self.index = index
87            self.nodeWeights = nodeWeights
88
89            var totalWeight: UInt64 = 0
90            for weight in nodeWeights.values {
91                totalWeight = totalWeight + weight
92            }
93            self.totalWeight = totalWeight
94            self.generatedVotes = {}
95            self.uniqueVoteMessageTotalWeights = {}
96        }
97
98        /// Returns the number of nodes in the cluster
99        access(all) fun size(): UInt16 {
100            return UInt16(self.nodeWeights.length) 
101        }
102
103        /// Returns the minimum sum of vote weight required in order to be able to generate a
104        /// valid quorum certificate for this cluster.
105        access(all) view fun voteThreshold(): UInt64 {
106            if self.totalWeight == 0 {
107                return 0
108            }
109
110            let floorOneThird = self.totalWeight / UInt64(3) // integer division, includes floor
111
112            var res = UInt64(2) * floorOneThird
113
114            let divRemainder = self.totalWeight % UInt64(3)
115
116            if divRemainder <= UInt64(1) {
117                res = res + UInt64(1)
118            } else {
119                res = res + divRemainder
120            }
121
122            return res
123        }
124
125        /// Returns the status of this cluster's QC process
126        /// If there is a number of weight for identical votes exceeding the `voteThreshold`,
127        /// Then this cluster's QC generation is considered complete and this method returns 
128        /// the vote message that reached quorum
129        /// If no vote is found to reach quorum, then `nil` is returned
130        access(all) view fun isComplete(): String? {
131            for message in self.uniqueVoteMessageTotalWeights.keys {
132                if self.uniqueVoteMessageTotalWeights[message]! >= self.voteThreshold() {
133                    return message
134                }
135            }
136            return nil
137        }
138
139        /// Generates the Quorum Certificate for this cluster
140        /// If the cluster is not complete, this returns `nil`
141        access(all) fun generateQuorumCertificate(): ClusterQC? {
142
143            // Only generate the QC if the voting is complete for this cluster
144            if let quorumMessage = self.isComplete() {
145
146                // Create a new empty QC
147                var certificate: ClusterQC = ClusterQC(index: self.index, signatures: [], message: quorumMessage, voterIDs: [])
148
149                // Add the signatures, messages, and node IDs only for votes
150                // that match the votes that reached quorum
151                for vote in self.generatedVotes.values {
152                    
153                    // Only count votes that were submitted
154                    if let submittedMessage = vote.message {
155                        if submittedMessage == quorumMessage {
156                            certificate.addSignature(vote.signature!)
157                            certificate.addVoterID(vote.nodeID)
158                        }
159                    }
160                }
161
162                return certificate
163            } else {
164                return nil
165            }
166        }
167
168        /// Gets a vote that was generated for a node ID
169        access(contract) view fun getGeneratedVote(nodeId: String): Vote? {
170            return self.generatedVotes[nodeId]
171        }
172
173        /// Sets the vote for the specified node ID
174        access(contract) fun setGeneratedVote(nodeId: String, vote: Vote) {
175            self.generatedVotes[nodeId] = vote
176        }
177
178        /// Gets the total weight commited for a unique vote
179        access(contract) view fun getUniqueVoteMessageTotalWeight(vote: String): UInt64? {
180            return self.uniqueVoteMessageTotalWeights[vote]
181        }
182
183        /// Sets the total weight for a unique vote
184        access(contract) fun setUniqueVoteMessageTotalWeight(vote: String, weight: UInt64) {
185            self.uniqueVoteMessageTotalWeights[vote] = weight
186        }
187    }
188
189    /// `Vote` represents a vote from one collection node. 
190    /// It simply contains strings with the signed message
191    /// the hex encoded message itself. Votes are aggregated to build quorum certificates
192    access(all) struct Vote {
193
194        /// The node ID from the staking contract
195        access(all) var nodeID: String
196
197        /// The signed message from the node (using the nodes `stakingKey`)
198        access(all) var signature: String?
199
200        /// The hex-encoded message for the vote
201        access(all) var message: String?
202
203        /// The index of the cluster that this vote (and node) is in
204        access(all) let clusterIndex: UInt16
205
206        /// The weight of the vote (and node)
207        access(all) let weight: UInt64
208
209        view init(nodeID: String, clusterIndex: UInt16, voteWeight: UInt64) {
210            pre {
211                nodeID.length == 64: "Voter ID must be a valid length node ID"
212            }
213            self.signature = nil
214            self.message = nil
215            self.nodeID = nodeID
216            self.clusterIndex = clusterIndex
217            self.weight = voteWeight
218        }
219
220        access(all) fun setSignature(_ signature: String) {
221            self.signature = signature
222        }
223
224        access(all) fun setMessage(_ message: String) {
225            self.message = message
226        }
227    }
228
229    /// Represents the quorum certificate for a specific cluster
230    /// and all the nodes/votes in the cluster
231    access(all) struct ClusterQC {
232
233        /// The index of the qc in the cluster record
234        access(all) let index: UInt16
235
236        /// The vote signatures from all the nodes in the cluster
237        access(all) var voteSignatures: [String]
238
239        /// The vote message from all the valid voters in the cluster
240        access(all) var voteMessage: String
241
242        /// The node IDs that correspond to each vote
243        access(all) var voterIDs: [String]
244
245        view init(index: UInt16, signatures: [String], message: String, voterIDs: [String]) {
246            self.index = index
247            self.voteSignatures = signatures
248            self.voteMessage = message
249            self.voterIDs = voterIDs
250        }
251
252        access(all) fun addSignature(_ signature: String) {
253            self.voteSignatures.append(signature)
254        }
255
256        access(all) fun addVoterID(_ voterID: String) {
257            self.voterIDs.append(voterID)
258        }
259    }
260
261    /// Represents the aggregated signature for a cluster quorum certificate.
262    access(all) struct ClusterQCVoteData {
263        /// The aggregated signature, hex-encoded. Includes one vote for each node in `voterIDs`.
264        access(all) let aggregatedSignature: String
265
266        /// The node IDs that contributed their vote to the aggregated signature.
267        access(all) let voterIDs: [String]
268
269        init(aggregatedSignature: String, voterIDs: [String]) {
270            self.aggregatedSignature = aggregatedSignature
271            self.voterIDs = voterIDs
272        }
273    }
274
275    /// The Voter resource is generated for each collection node after they register.
276    /// Each resource instance is good for all future potential epochs, but will
277    /// only be valid if the node operator has been confirmed as a collector node for the next epoch.
278    access(all) resource Voter {
279
280        /// The nodeID of the voter (from the staking contract)
281        access(all) let nodeID: String
282
283        /// The staking key of the node (from the staking contract)
284        access(all) var stakingKey: String
285
286        init(nodeID: String, stakingKey: String) {
287            pre {
288                !FlowClusterQC.voterIsClaimed(nodeID): "Cannot create a Voter resource for a node ID that has already been claimed"
289            }
290
291            self.nodeID = nodeID
292            self.stakingKey = stakingKey
293            FlowClusterQC.voterClaimed[nodeID] = true
294        }
295
296        /// Submits the given vote. Can be called only once per epoch
297        /// 
298        /// Params: voteSignature: Signed `voteMessage` with the nodes `stakingKey`
299        ///         voteMessage: Hex-encoded message
300        ///
301        access(all) fun vote(voteSignature: String, voteMessage: String) {
302            pre {
303                FlowClusterQC.inProgress: "Voting phase is not in progress"
304                voteSignature.length > 0: "Vote signature must not be empty"
305                voteMessage.length > 0: "Vote message must not be empty"
306                !FlowClusterQC.nodeHasVoted(self.nodeID): "Vote must not have been cast already"
307            }
308
309            // Get the public key object from the stored key
310            let publicKey = PublicKey(
311                publicKey: self.stakingKey.decodeHex(),
312                signatureAlgorithm: SignatureAlgorithm.BLS_BLS12_381
313            )
314
315            // Check to see that the signature on the message is valid 
316            let isValid = publicKey.verify(
317                signature: voteSignature.decodeHex(),
318                signedData: voteMessage.decodeHex(),
319                domainSeparationTag: "FLOW-Collector_Vote-V00-CS00-with-",
320                hashAlgorithm: HashAlgorithm.KMAC128_BLS_BLS12_381
321            )
322
323            // Assert the validity
324            assert (
325                isValid,
326                message: "Vote Signature cannot be verified"
327            )
328
329            // Get the cluster that this node belongs to
330            let clusterIndex = FlowClusterQC.nodeCluster[self.nodeID]
331                ?? panic("This node cannot vote during the current epoch")
332            let cluster = FlowClusterQC.clusters[clusterIndex]!
333
334            // Get this node's allocated vote
335            let vote = cluster.getGeneratedVote(nodeId: self.nodeID)!
336
337            // Set the signature and message fields
338            vote.setSignature(voteSignature)
339            vote.setMessage(voteMessage)
340
341            // Set the new total weight for the vote
342            let totalWeight = cluster.getUniqueVoteMessageTotalWeight(vote: voteMessage) ?? 0
343            var newWeight = totalWeight + vote.weight
344            cluster.setUniqueVoteMessageTotalWeight(vote: voteMessage, weight: newWeight)
345
346            // Save the modified vote and cluster back
347            cluster.setGeneratedVote(nodeId: self.nodeID, vote: vote)
348            FlowClusterQC.clusters[clusterIndex] = cluster
349        }
350
351    }
352
353    /// Interface that only contains operations that are part
354    /// of the regular automated functioning of the epoch process
355    /// These are accessed by the `FlowEpoch` contract through a capability
356    access(all) resource interface EpochOperations {
357        access(all) fun createVoter(nodeID: String, stakingKey: String): @Voter
358        access(all) fun startVoting(clusters: [Cluster]) 
359        access(all) fun stopVoting()
360        access(all) fun forceStopVoting()
361    }
362
363    /// The Admin resource provides the ability to create to Voter resource objects,
364    /// begin voting, and end voting for an epoch
365    access(all) resource Admin: EpochOperations {
366
367        /// Creates a new Voter resource for a collection node
368        /// This function will be publicly accessible in the FlowEpoch
369        /// contract, which will restrict the creation to only collector nodes
370        access(all) fun createVoter(nodeID: String, stakingKey: String): @Voter {
371            return <-create Voter(nodeID: nodeID, stakingKey: stakingKey)
372        }
373
374        /// Configures the contract for the next epoch's clusters
375        ///
376        /// NOTE: This will be called by the top-level FlowEpochs contract upon
377        /// transitioning to the Epoch Setup Phase.
378        ///
379        /// CAUTION: calling this erases the votes for the current/previous epoch.
380        access(all) fun startVoting(clusters: [Cluster]) {
381            FlowClusterQC.inProgress = true
382            FlowClusterQC.clusters = clusters
383
384            var clusterIndex: UInt16 = 0
385            for cluster in clusters {
386
387                // Create a new Vote struct for each participating node
388                for nodeID in cluster.nodeWeights.keys {
389                    cluster.setGeneratedVote(nodeId: nodeID,vote: Vote(nodeID: nodeID, clusterIndex: clusterIndex, voteWeight: cluster.nodeWeights[nodeID]!))
390                    FlowClusterQC.nodeCluster[nodeID] = clusterIndex                   
391                }
392                
393                FlowClusterQC.clusters[clusterIndex] = cluster
394                clusterIndex = clusterIndex + UInt16(1)
395            }
396        }
397
398        /// Stops voting for the current epoch. Can only be called once a 2/3 
399        /// majority of each cluster has submitted a vote. 
400        access(all) fun stopVoting() {
401            pre {
402                FlowClusterQC.votingCompleted(): "Voting must be complete before it can be stopped"
403            }
404            FlowClusterQC.inProgress = false
405        }
406
407        /// Force a stop of the voting period
408        /// Should only be used if the protocol halts and needs to be reset
409        access(all) fun forceStopVoting() {
410            FlowClusterQC.inProgress = false
411        }
412    }
413
414    /// Returns a boolean telling if the voter is registered for the current voting phase
415    access(all) view fun voterIsRegistered(_ nodeID: String): Bool {
416        return FlowClusterQC.nodeCluster[nodeID] != nil
417    }
418
419    /// Returns a boolean telling if the node has claimed their `Voter` resource object
420    /// The object can only be claimed once, but if the node destroys their `Voter` object,
421    /// It could be claimed again
422    access(all) view fun voterIsClaimed(_ nodeID: String): Bool {
423        return FlowClusterQC.voterClaimed[nodeID] != nil
424    }
425
426    /// Returns whether this voter has successfully submitted a vote for this epoch.
427    access(all) view fun nodeHasVoted(_ nodeID: String): Bool {
428
429        // Get the cluster that this node belongs to
430        if let clusterIndex = FlowClusterQC.nodeCluster[nodeID] {
431            let cluster = FlowClusterQC.clusters[clusterIndex]
432
433            // If the node is registered for this epoch,
434            // check to see if they have voted
435            if cluster.nodeWeights[nodeID] != nil {
436                return cluster.generatedVotes[nodeID]!.signature != nil
437            }
438        }
439
440        return false
441    }
442
443    /// Gets all of the collector clusters for the current epoch
444    access(all) view fun getClusters(): [Cluster] {
445        return self.clusters
446    }
447
448    /// Returns true if we have collected enough votes for all clusters.
449    access(all) view fun votingCompleted(): Bool {
450        for cluster in FlowClusterQC.clusters {
451            if cluster.isComplete() == nil {
452                return false
453            }
454        }
455        return true
456    }
457
458    init() {
459        self.AdminStoragePath = /storage/flowEpochsQCAdmin
460        self.VoterStoragePath = /storage/flowEpochsQCVoter
461
462        self.inProgress = false 
463        
464        self.clusters = []
465        self.voterClaimed = {}
466        self.nodeCluster = {}
467
468        self.account.storage.save(<-create Admin(), to: self.AdminStoragePath)
469    }
470}