Smart Contract

NodeVersionBeacon

A.e467b9dd11fa00df.NodeVersionBeacon

Deployed

1w ago
Feb 14, 2026, 03:27:22 PM UTC

Dependents

6 imports
1/// The NodeVersionBeacon contract holds the past and future protocol versions.
2/// that should be used to execute/handle blocks at aa given block height.
3///
4/// The service account holds the NodeVersionBeacon.Heartbeat resource
5/// which is responsible for emitting the VersionBeacon event.
6/// The event contains the current version and all the upcoming versions.
7/// The event is emitted every time the version table is updated
8/// or a version boundary is reached.
9///
10/// The NodeVersionBeacon.Admin resource is used to add new version boundaries
11/// or change existing future version boundaries. Future version boundaries can only be
12/// changed if they occur after the current block height + versionUpdateFreezePeriod.
13/// This is to ensure that nodes have enough time to react to version table changes.
14/// The versionUpdateFreezePeriod can also be changed by the admin resource, but only if
15/// there are no upcoming version boundaries within the current versionUpdateFreezePeriod or
16/// the new versionUpdateFreezePeriod.
17///
18/// The contract itself can be used to query the current version and the next upcoming version.
19access(all) contract NodeVersionBeacon {
20
21    /// =========================
22    /// Execution State Versioning
23    /// =========================
24
25    /// Struct representing software version as Semantic Version
26    /// along with helper functions
27    /// For reference, see https://semver.org/
28    access(all) struct Semver {
29        /// Components defining a semantic version
30        access(all) let major: UInt8
31        access(all) let minor: UInt8
32        access(all) let patch: UInt8
33        access(all) let preRelease: String?
34
35        init(major: UInt8, minor: UInt8, patch: UInt8, preRelease: String?) {
36            self.major = major
37            self.minor = minor
38            self.patch = patch
39            self.preRelease = preRelease
40        }
41
42        /// Returns version in Semver format (e.g. v<major>.<minor>.<patch>-<preRelease>)
43        /// as a String
44        access(all) view fun toString(): String {
45            let semverCoreString = self.major.toString()
46                 .concat(".")
47                 .concat(
48                     self.minor.toString()
49                 ).concat(".")
50                 .concat(
51                     self.patch.toString()
52                 )
53            // Concat pre-release if it exists & return
54            if self.preRelease != nil {
55                return semverCoreString.concat("-").concat(self.preRelease!)
56            }
57
58            return semverCoreString
59        }
60
61        /* Custom Comparators */
62
63        /// Returns true if Semver core is greater than
64        /// passed Semver core and false otherwise
65        access(all) view fun coreGreaterThan(_ other: Semver): Bool {
66            if (self.major != other.major) {
67                return self.major > other.major
68            }
69
70            if (self.minor != other.minor) {
71                return self.minor > other.minor
72            }
73
74            if (self.patch != other.patch) {
75                return self.patch > other.patch
76            }
77
78            return false
79        }
80
81        /// Returns true if Semver core is greater than or
82        /// equal to passed Semver core and false otherwise
83        access(all) view fun coreGreaterThanOrEqualTo(_ other: Semver): Bool {
84            return self.coreGreaterThan(other) || self.coreEqualTo(other)
85        }
86
87        /// Returns true if Semver core is less than
88        /// passed Semver core and false otherwise
89        access(all) view fun coreLessThan(_ other: Semver): Bool {
90            return !self.coreGreaterThanOrEqualTo(other)
91        }
92
93        /// Returns true if Semver core is less than or
94        /// equal to passed Semver core and false otherwise
95        access(all) view fun coreLessThanOrEqualTo(_ other: Semver): Bool {
96            return !self.coreGreaterThan(other)
97        }
98
99        /// Returns true if Semver is equal to passed
100        /// Semver core and false otherwise
101        access(all) view fun coreEqualTo(_ other: Semver): Bool {
102            return self.major == other.major && self.minor == other.minor && self.patch == other.patch
103        }
104
105        /// Returns true if Semver is *exactly* equal to passed
106        /// Semver and false otherwise
107        access(all) view fun strictEqualTo(_ other: Semver): Bool {
108            return self.coreEqualTo(other) && self.preRelease == other.preRelease
109        }
110    }
111
112    /// Returns the v0.0.0 version.
113    access(all) fun zeroSemver(): Semver {
114        return Semver(major: 0, minor: 0, patch: 0, preRelease: nil)
115    }
116
117    /// Struct for emitting the current and incoming versions along with their block
118    access(all) struct VersionBoundary {
119        access(all) let blockHeight: UInt64
120        access(all) let version: Semver
121
122        init(blockHeight: UInt64, version: Semver){
123            self.blockHeight = blockHeight
124            self.version = version
125        }
126    }
127
128    /// Returns the zero boundary. Used as a sentinel value
129    /// for versions before the version beacon contract.
130    /// Simplifies edge case code.
131    /// The zero boundary is at block height 0 and has version v0.0.0.
132    /// It is always the first element in the versionBoundaryBlockList.
133    access(all) fun zeroVersionBoundary(): VersionBoundary {
134        let zeroVersion = self.zeroSemver()
135        return VersionBoundary(
136            blockHeight: 0,
137            version: zeroVersion
138        )
139    }
140
141    /// A service event emitted when the version table is updated.
142    /// The version is the software version which must be used for executing a height range of blocks.
143    /// The version pertains to Execution and Verification Nodes.
144    /// The table contains the current version and all the upcoming versions sorted by block height.
145    /// The sequence increases by one each time an event is emitted. 
146    /// It can be used to verify no events were missed.
147    access(all) event VersionBeacon(
148        versionBoundaries: [VersionBoundary],
149        sequence: UInt64
150    )
151
152    /// Event emitted any time the version boundary freeze period is updated.
153    /// freeze period is measured in blocks (from the current block).
154    access(all) event NodeVersionBoundaryFreezePeriodChanged(freezePeriod: UInt64)
155
156    /// Canonical storage path for the NodeVersionBeacon.Admin resource.
157    access(all) let AdminStoragePath: StoragePath
158
159    /// Canonical storage path for the NodeVersionBeacon.Heartbeat resource.
160    access(all) let HeartbeatStoragePath: StoragePath
161
162    /// Block height indexed version boundaries.
163    access(contract) let versionBoundary: {UInt64: VersionBoundary}
164
165    /// Sorted Array containing version boundary block heights.
166    access(contract) var versionBoundaryBlockList: [UInt64]
167
168    /// Index in the versionBoundaryBlockList of the next upcoming version boundary,
169    /// or nil if no upcoming version boundary.
170    access(contract) var firstUpcomingBoundary: UInt64?
171
172    /// versionUpdateFreezePeriod is the number of blocks (past the current one) for which version boundary
173    /// changes are not allowed. This is to ensure that nodes have enough time to react to
174    /// version table changes.
175    access(contract) var versionBoundaryFreezePeriod: UInt64
176
177    /// Boolean flag for keeping track if a VersionBeacon event needs to be emitted on next heartbeat.
178    access(contract) var emitEventOnNextHeartbeat: Bool
179
180    /// A counter that increases every time the Version beacon event is emitted.
181    access(contract) var nextVersionBeaconEventSequence: UInt64
182
183    /// Admin resource that manages version boundaries
184    /// maintained in this contract.
185    access(all) resource Admin {
186        /// Adds or updates a version boundary.
187        access(all) fun setVersionBoundary(versionBoundary: VersionBoundary) {
188            pre {
189                versionBoundary.blockHeight > getCurrentBlock().height + NodeVersionBeacon.versionBoundaryFreezePeriod
190                    : "Cannot set/update a version boundary for past blocks or blocks in the near future."
191            }
192            // Set the flag to true so the event will be emitted next time emitChanges is called
193            NodeVersionBeacon.emitEventOnNextHeartbeat = true
194
195            let exists = NodeVersionBeacon.versionBoundary[versionBoundary.blockHeight] != nil
196            NodeVersionBeacon.versionBoundary[versionBoundary.blockHeight] = versionBoundary
197
198            if exists {
199                // this was an update so nothing else needs to be done
200                return
201            }
202
203            // We have to insert the block height into the ordered list.
204            // This is an inefficient algorithm, but it is not expected that the list of
205            // upcoming versions will be long.
206            var i = NodeVersionBeacon.versionBoundaryBlockList.length
207            while i > 1 && NodeVersionBeacon.versionBoundaryBlockList[i-1] > versionBoundary.blockHeight  {
208                i = i - 1
209            }
210            NodeVersionBeacon.versionBoundaryBlockList.insert(at: i, versionBoundary.blockHeight)
211
212            // no need to change the firstUpcomingBoundary unless it was nil
213            // case 1: index points to a lower block height then the one inserted
214            // => it should remain pointing at that index
215            // case 2: index points to the entry that was replaced by this insert
216            // => it should remain pointing at this new entry, since it is before the old one
217            // case 3: index was pointing to an entry later than the insert FixedPoint
218            // => this is illegal and cannot happen since there are entries with lower block heights.
219            if NodeVersionBeacon.firstUpcomingBoundary == nil {
220                NodeVersionBeacon.firstUpcomingBoundary = UInt64(NodeVersionBeacon.versionBoundaryBlockList.length - 1)
221            }
222        }
223
224        /// Deletes an upcoming version boundary.
225        access(all) fun deleteVersionBoundary(blockHeight: UInt64) {
226            pre {
227                blockHeight > getCurrentBlock().height + NodeVersionBeacon.versionBoundaryFreezePeriod
228                    : "Cannot delete a version for past blocks or blocks in the near future."
229                NodeVersionBeacon.versionBoundary.containsKey(blockHeight): "No boundary defined at that blockHeight."
230            }
231            // Set the flag to true so the event will be emitted next time emitChanges is called
232            NodeVersionBeacon.emitEventOnNextHeartbeat = true
233
234            // Remove the version mapping and upcomingBlockBoundaries
235            NodeVersionBeacon.versionBoundary.remove(key: blockHeight)
236
237            // We have to remove the block height from the ordered list.
238            // This is an inefficient algorithm, but it is not expected that the list of
239            // upcoming versions will be long.
240            var i = NodeVersionBeacon.versionBoundaryBlockList.length - 1
241            while i > 0 && NodeVersionBeacon.versionBoundaryBlockList[i] > blockHeight  {
242                i = i - 1
243            }
244            assert(NodeVersionBeacon.versionBoundaryBlockList[i] == blockHeight,
245             message: "version boundary exists in map, so it should also exist in the ordered list")
246
247            NodeVersionBeacon.versionBoundaryBlockList.remove(at: i)
248
249            // the index has to be fixed, but you cannot change records before the index
250            // so the only case to be addressed is that the index is pointing off the list,
251            // because the list is now shorter.
252            if NodeVersionBeacon.firstUpcomingBoundary != nil &&
253                NodeVersionBeacon.firstUpcomingBoundary! >= UInt64(NodeVersionBeacon.versionBoundaryBlockList.length) {
254                NodeVersionBeacon.firstUpcomingBoundary = nil
255            }
256
257        }
258
259        /// Updates the number of blocks in which version boundaries are frozen.
260        access(all) fun setVersionBoundaryFreezePeriod(newFreezePeriod: UInt64) {
261            post {
262                NodeVersionBeacon.versionBoundaryFreezePeriod == newFreezePeriod: "Update buffer was not properly set!"
263            }
264
265            // Get current block height.
266            let currentBlockHeight = getCurrentBlock().height
267
268            // No boundaries defined beyond current block, safe to make changes
269            if NodeVersionBeacon.firstUpcomingBoundary == nil {
270                NodeVersionBeacon.versionBoundaryFreezePeriod = newFreezePeriod
271                return
272            }
273
274            let nextBlockBoundary = NodeVersionBeacon.versionBoundaryBlockList[NodeVersionBeacon.firstUpcomingBoundary!]
275
276            // Ensure that the we're not currently within the old or new freeze period
277            // of the next block height boundary
278            assert(
279                currentBlockHeight + NodeVersionBeacon.versionBoundaryFreezePeriod < nextBlockBoundary &&
280                currentBlockHeight + newFreezePeriod < nextBlockBoundary,
281                message: "Updating buffer now breaks version boundary update expectations. Try updating buffer after next version boundary."
282            )
283
284            NodeVersionBeacon.versionBoundaryFreezePeriod = newFreezePeriod
285
286            emit NodeVersionBoundaryFreezePeriodChanged(freezePeriod: newFreezePeriod)
287        }
288
289        /// Emits the given protocol state version upgrade event.
290        /// If the version and active view are valid, this will cause the Protocol State
291        /// to upgrade its model version when the event is incorporated.
292        /// If either the version or active view are invalid, the service event will be
293        /// ignored and will have no effect. All validation is performed when the service
294        /// event is incorporated by the Protocol State.
295        /// It is safe to emit the same ProtocolStateVersionUpgrade event multiple times,
296        /// as only version upgrade will occur.
297        access(all) fun emitProtocolStateVersionUpgrade(newProtocolVersion: UInt64, activeView: UInt64) {
298            emit ProtocolStateVersionUpgrade(newProtocolVersion: newProtocolVersion, activeView: activeView)
299        }
300    }
301
302    /// Heartbeat resource that emits the version beacon event and keeps track of upcoming versions.
303    /// This resource should always be held only by the service account,
304    /// because the service account should be the only one emitting the event,
305    /// and only during the system transaction
306    access(all) resource Heartbeat {
307        // heartbeat is called during the system transaction every block.
308        access(all) fun heartbeat() {
309            self.checkFirstUpcomingBoundary()
310
311            if (!NodeVersionBeacon.emitEventOnNextHeartbeat) {
312                return
313            }
314            NodeVersionBeacon.emitEventOnNextHeartbeat = false
315
316            self.emitVersionBeaconEvent(versionBoundaries: NodeVersionBeacon.getCurrentVersionBoundaries())
317        }
318
319        access(self) fun emitVersionBeaconEvent(versionBoundaries : [VersionBoundary]) {
320
321            emit VersionBeacon(versionBoundaries: versionBoundaries,
322                sequence: NodeVersionBeacon.nextVersionBeaconEventSequence)
323            // After emitting the event increase the event sequence number and set the flag to false
324            // so the event won't be emitted on the next block if there isn't any changes to the table
325            NodeVersionBeacon.nextVersionBeaconEventSequence = NodeVersionBeacon.nextVersionBeaconEventSequence + 1
326
327        }
328
329        /// Check if the index pointing to the next version boundary needs to be moved.
330        access(self) fun checkFirstUpcomingBoundary() {
331            if NodeVersionBeacon.firstUpcomingBoundary == nil {
332                return
333            }
334
335            let currentBlockHeight = getCurrentBlock().height
336            var boundaryIndex =  NodeVersionBeacon.firstUpcomingBoundary!
337            while boundaryIndex < UInt64(NodeVersionBeacon.versionBoundaryBlockList.length)
338              && NodeVersionBeacon.versionBoundaryBlockList[boundaryIndex] <= currentBlockHeight {
339                boundaryIndex = boundaryIndex + 1
340            }
341
342            if boundaryIndex == NodeVersionBeacon.firstUpcomingBoundary! {
343                // no change
344                return
345            }
346
347            if boundaryIndex >= UInt64(NodeVersionBeacon.versionBoundaryBlockList.length) {
348                NodeVersionBeacon.firstUpcomingBoundary = nil
349            } else {
350                NodeVersionBeacon.firstUpcomingBoundary = boundaryIndex
351            }
352
353            // If we passed a boundary re-emit the VersionBeacon event
354            NodeVersionBeacon.emitEventOnNextHeartbeat = true
355        }
356    }
357
358    /// getCurrentVersionBoundaries returns the current version boundaries.
359    /// this is the same list as the one emitted by the VersionBeacon event.
360    access(all) fun getCurrentVersionBoundaries(): [VersionBoundary] {
361            let tableUpdates: [VersionBoundary] = []
362
363            if NodeVersionBeacon.firstUpcomingBoundary == nil {
364                // no future boundaries. Just return the last one.
365                // this is safe, there is at least one record in the versionBoundaryBlockList
366                tableUpdates.append(NodeVersionBeacon.versionBoundary[
367                    NodeVersionBeacon.versionBoundaryBlockList[NodeVersionBeacon.versionBoundaryBlockList.length - 1]
368                ]!)
369                return tableUpdates
370            }
371
372            // -1 to include the version the node should currently be on
373            var start = (NodeVersionBeacon.firstUpcomingBoundary ?? UInt64(NodeVersionBeacon.versionBoundaryBlockList.length)) - 1
374            let end = UInt64(NodeVersionBeacon.versionBoundaryBlockList.length)
375
376            if start < 0 {
377                // this is the case when the current index is at 0
378                start = 0
379            }
380
381            var i = start
382
383            while i < end {
384                let block = NodeVersionBeacon.versionBoundaryBlockList[i]
385                tableUpdates.append(NodeVersionBeacon.versionBoundary[block]!)
386                i = i + 1
387            }
388
389            return tableUpdates
390    }
391
392    /// Returns the versionBoundaryFreezePeriod
393    access(all) view fun getVersionBoundaryFreezePeriod(): UInt64 {
394        return NodeVersionBeacon.versionBoundaryFreezePeriod
395    }
396
397    /// Returns the sequence number of the next version beacon event
398    /// This can be used to verify that no version beacon events were missed.
399    access(all) view fun getNextVersionBeaconSequence(): UInt64 {
400        return self.nextVersionBeaconEventSequence
401    }
402
403    /// Function that returns the version that was defined at the most
404    /// recent block height boundary. May return zero boundary.
405    access(all) fun getCurrentVersionBoundary(): VersionBoundary {
406        var current: UInt64 = 0
407
408        // index is never 0 since version 0 is always in the past
409        if let index = NodeVersionBeacon.firstUpcomingBoundary {
410            assert(index > 0, message: "index should never be 0 since version 0 is always in the past")
411            current = self.versionBoundaryBlockList[index-1]
412        } else {
413            current = UInt64(NodeVersionBeacon.versionBoundaryBlockList.length - 1)
414        }
415
416        let block = self.versionBoundaryBlockList[current]
417
418        // Return the version mapped to the last historical block height boundary
419        return self.versionBoundary[block]!
420    }
421
422    access(all) fun getNextVersionBoundary() : VersionBoundary? {
423        if let index = NodeVersionBeacon.firstUpcomingBoundary {
424            let block = self.versionBoundaryBlockList[index]
425            return self.versionBoundary[block]
426        } else {
427            return nil
428        }
429    }
430
431    /// Checks whether given version was compatible at the given historical block height
432    access(all) view fun getVersionBoundary(effectiveAtBlockHeight: UInt64): VersionBoundary {
433        let block = self.searchForClosestHistoricalBlockBoundary(blockHeight: effectiveAtBlockHeight)
434
435        return self.versionBoundary[block]!
436    }
437
438    access(all) struct VersionBoundaryPage {
439        access(all) let page: Int
440        access(all) let perPage: Int
441        access(all) let totalLength: Int
442        access(all) let values : [VersionBoundary]
443
444        view init(page: Int, perPage: Int, totalLength: Int, values: [VersionBoundary]) {
445            self.page = page
446            self.perPage = perPage
447            self.totalLength = totalLength
448            self.values = values
449        }
450
451    }
452
453    /// Returns a page of version boundaries
454    /// page is zero based
455    /// results are sorted by block height
456    access(all) fun getVersionBoundariesPage(page: Int, perPage: Int) : VersionBoundaryPage {
457        pre {
458            page >= 0: "page must be greater than or equal to 0"
459            perPage > 0: "perPage must be greater than 0"
460        }
461
462        let totalLength = NodeVersionBeacon.versionBoundaryBlockList.length
463        var startIndex = page * perPage
464        if startIndex > totalLength {
465            startIndex = totalLength
466        }
467        var endIndex = startIndex + perPage
468        if endIndex > totalLength {
469            endIndex = totalLength
470        }
471        let values: [VersionBoundary] = []
472        if startIndex == endIndex {
473            return VersionBoundaryPage(page: page, perPage: perPage, totalLength: totalLength, values: values)
474        }
475        for block in self.versionBoundaryBlockList.slice(from: startIndex, upTo: endIndex) {
476            values.append(NodeVersionBeacon.versionBoundary[block]!)
477        }
478        return VersionBoundaryPage(page: page, perPage: perPage, totalLength: totalLength, values: values)
479    }
480
481
482    /// Binary search algorithm to find closest value key in versionTable that is <= target value
483    access(contract) view fun searchForClosestHistoricalBlockBoundary(blockHeight: UInt64): UInt64 {
484        // Return last block boundary if target is beyond
485        let length = self.versionBoundaryBlockList.length
486        if blockHeight >= self.versionBoundaryBlockList[length - 1] {
487            return self.versionBoundaryBlockList[length - 1]
488        }
489
490        // Define search bounds
491        var left = 0
492        var right = length
493        // Loop until search pointers cross
494        while left < right {
495            var mid = (left + right) / 2
496            if self.versionBoundaryBlockList[mid] == blockHeight {
497                return self.versionBoundaryBlockList[mid]
498            }
499            if blockHeight < self.versionBoundaryBlockList[mid] {
500                if mid > 0 && blockHeight > self.versionBoundaryBlockList[mid -1] {
501                    return self.versionBoundaryBlockList[mid - 1]
502                }
503                right = mid
504            } else {
505                if mid < (length - 1) && blockHeight < self.versionBoundaryBlockList[mid + 1] {
506                    return self.versionBoundaryBlockList[mid]
507                }
508                left = mid + 1
509            }
510        }
511        // Return zero version if nothing found
512        return self.versionBoundaryBlockList[0]
513    }
514
515    /// =========================
516    /// Protocol State Versioning
517    /// =========================
518
519    /// A service event which is emitted to indicate that the Protocol State version is being upgraded.
520    /// This acts as a signal to begin using the upgraded Protocol State version 
521    /// after this service event is sealed, and after view `activeView` is entered.
522    /// Nodes running a software version which does not support `newProtocolVersion`
523    /// will stop processing new blocks when they reach view `activeAtView`.
524    access(all) event ProtocolStateVersionUpgrade(newProtocolVersion: UInt64, activeView: UInt64)
525
526    init(versionUpdateFreezePeriod: UInt64) {
527        self.AdminStoragePath = /storage/NodeVersionBeaconAdmin
528        self.HeartbeatStoragePath = /storage/NodeVersionBeaconHeartbeat
529
530        // insert a zero-th version to make the API simpler and more robust
531        let zero = NodeVersionBeacon.zeroVersionBoundary()
532
533        self.versionBoundary = {zero.blockHeight:zero}
534        self.versionBoundaryBlockList = [zero.blockHeight]
535        self.versionBoundaryFreezePeriod = versionUpdateFreezePeriod
536        self.firstUpcomingBoundary = nil
537        self.nextVersionBeaconEventSequence = 0
538
539        // emit the event on the first heartbeat to send the zero version
540        self.emitEventOnNextHeartbeat = true
541
542        self.account.storage.save(<-create Admin(), to: self.AdminStoragePath)
543        self.account.storage.save(<-create Heartbeat(), to: self.HeartbeatStoragePath)
544    }
545}
546