Smart Contract

FlowCronUtils

A.6dec6e64a13b881e.FlowCronUtils

Valid From

136,510,229

Deployed

1w ago
Feb 15, 2026, 02:54:06 PM UTC

Dependents

0 imports
1/// FlowCronUtils: A Cadence contract for computing next run timestamps 
2/// from standard 5-field cron expressions on Flow blockchain.
3///
4/// CRON FORMAT: minute (0-59) hour (0-23) day-of-month (1-31) month (1-12) day-of-week (0-6, 0=Sun)
5/// OPERATORS: * (wildcard), , (lists), - (ranges), / (steps including */n and a-b/n)
6/// DOM/DOW SEMANTICS (Vixie rule): If both DOM and DOW constrained, day matches if DOM OR DOW matches
7/// TIME BASIS: Flow blockchain canonical time (getCurrentBlock().timestamp), treated as UTC-like chain time
8/// HORIZON: +5 years maximum lookahead from any given timestamp
9access(all) contract FlowCronUtils {
10
11    /// DateTime struct for holding date/time components
12    access(all) struct DateTime {
13        access(all) let year: Int
14        access(all) let month: Int
15        access(all) let day: Int
16        access(all) let hour: Int
17        access(all) let minute: Int
18
19        init(year: Int, month: Int, day: Int, hour: Int, minute: Int) {
20            self.year = year
21            self.month = month
22            self.day = day
23            self.hour = hour
24            self.minute = minute
25        }
26    }
27
28    /// Container for parsed cron specification as bitmasks
29    access(all) struct CronSpec {
30        access(all) let minMask: UInt64      // bits 0-59 for minutes
31        access(all) let hourMask: UInt32     // bits 0-23 for hours  
32        access(all) let domMask: UInt32      // bits 1-31 for day-of-month
33        access(all) let monthMask: UInt16    // bits 1-12 for month
34        access(all) let dowMask: UInt8       // bits 0-6 for day-of-week (0=Sunday)
35        access(all) let domIsStar: Bool      // true if DOM field was "*"
36        access(all) let dowIsStar: Bool      // true if DOW field was "*"
37
38        init(
39            minMask: UInt64, 
40            hourMask: UInt32, 
41            domMask: UInt32,
42            monthMask: UInt16, 
43            dowMask: UInt8, 
44            domIsStar: Bool, 
45            dowIsStar: Bool
46        ) {
47            self.minMask = minMask
48            self.hourMask = hourMask
49            self.domMask = domMask
50            self.monthMask = monthMask
51            self.dowMask = dowMask
52            self.domIsStar = domIsStar
53            self.dowIsStar = dowIsStar
54        }
55    }
56
57    /// Core function: compute next run timestamp strictly greater than afterUnix
58    /// Returns nil if no match found within +5 years horizon
59    access(all) fun nextTick(spec: CronSpec, afterUnix: UInt64): UInt64? {
60        // Round up to next minute boundary
61        let roundedUp = afterUnix + 60 - (afterUnix % 60)
62        let dateTime = self.ymdhmFromUnix(t: roundedUp)
63        let year = dateTime.year
64        let month = dateTime.month
65        let day = dateTime.day
66        let hour = dateTime.hour
67        let minute = dateTime.minute
68        
69        let horizonYear = year + 5
70        var currentY = year
71        var currentM = month  
72        var currentD = day
73        var currentH = hour
74        var currentMin = minute
75
76        while currentY <= horizonYear {
77            // Month step
78            if !self.hasBit(mask: UInt64(spec.monthMask), pos: currentM) {
79                let nextM = self.nextSetBit(mask: UInt64(spec.monthMask), pos: currentM, maxPos: 12)
80                if nextM != nil && nextM! <= 12 {
81                    currentM = nextM!
82                    currentD = 1
83                    currentH = 0
84                    currentMin = 0
85                } else {
86                    // Carry to next year
87                    currentY = currentY + 1
88                    currentM = self.nextSetBit(mask: UInt64(spec.monthMask), pos: 1, maxPos: 12) ?? 1
89                    currentD = 1
90                    currentH = 0
91                    currentMin = 0
92                    continue
93                }
94            }
95
96            // Day step with DOM/DOW logic
97            let daysInCurrentMonth = self.daysInMonth(year: currentY, month: currentM)
98            let allowedDayMask = self.getAllowedDayMask(spec, currentY, currentM, daysInCurrentMonth)
99            
100            if !self.hasBit(mask: UInt64(allowedDayMask), pos: currentD) {
101                let nextD = self.nextSetBit(mask: UInt64(allowedDayMask), pos: currentD, maxPos: daysInCurrentMonth)
102                if nextD != nil && nextD! <= daysInCurrentMonth {
103                    currentD = nextD!
104                    currentH = 0
105                    currentMin = 0
106                } else {
107                    // Carry to next month
108                    currentM = currentM + 1
109                    currentD = 1
110                    currentH = 0
111                    currentMin = 0
112                    continue
113                }
114            }
115
116            // Hour step
117            if !self.hasBit(mask: UInt64(spec.hourMask), pos: currentH) {
118                let nextH = self.nextSetBit(mask: UInt64(spec.hourMask), pos: currentH, maxPos: 23)
119                if nextH != nil && nextH! <= 23 {
120                    currentH = nextH!
121                    currentMin = 0
122                } else {
123                    // Carry to next day
124                    currentD = currentD + 1
125                    currentH = 0
126                    currentMin = 0
127                    continue
128                }
129            }
130
131            // Minute step
132            if !self.hasBit(mask: spec.minMask, pos: currentMin) {
133                let nextMin = self.nextSetBit(mask: spec.minMask, pos: currentMin, maxPos: 59)
134                if nextMin != nil && nextMin! <= 59 {
135                    currentMin = nextMin!
136                } else {
137                    // Carry to next hour
138                    currentH = currentH + 1
139                    currentMin = 0
140                    continue
141                }
142            }
143
144            // All fields match - return the timestamp
145            return self.unixFromYMDHM(y: currentY, m: currentM, d: currentD, h: currentH, mi: currentMin)
146        }
147
148        return nil // Exceeded horizon
149    }
150
151    /// Parse a standard 5-field cron expression into CronSpec
152    /// Supports operators: * , - / (including */n and a-b/n)
153    access(all) fun parse(expression: String): CronSpec? {
154        let fields = expression.split(separator: " ")
155        if fields.length != 5 {
156            return nil
157        }
158
159        // Access array elements safely
160        let minField = fields[0]
161        let hourField = fields[1]
162        let domField = fields[2]
163        let monthField = fields[3]
164        let dowField = fields[4]
165
166        let minMask = self.parseField(minField, 0, 59)
167        let hourMask = self.parseField(hourField, 0, 23) 
168        let domMask = self.parseField(domField, 1, 31)
169        let monthMask = self.parseField(monthField, 1, 12)
170        let dowMask = self.parseField(dowField, 0, 6)
171
172        if minMask == nil || hourMask == nil || domMask == nil || monthMask == nil || dowMask == nil {
173            return nil
174        }
175
176        return CronSpec(
177            minMask: minMask!,
178            hourMask: UInt32(hourMask! & 0xFFFFFF), // 24 bits
179            domMask: UInt32(domMask! & 0xFFFFFFFE), // clear bit 0, use bits 1-31
180            monthMask: UInt16(monthMask! & 0x1FFE), // clear bit 0, use bits 1-12
181            dowMask: UInt8(dowMask! & 0x7F), // 7 bits
182            domIsStar: domField == "*",
183            dowIsStar: dowField == "*"
184        )
185    }
186
187    /// Parse a single cron field into bitmask
188    access(contract) fun parseField(_ field: String, _ min: Int, _ max: Int): UInt64? {
189        if field == "*" {
190            return self.rangeMask(min, max)
191        }
192
193        var mask: UInt64 = 0
194        let parts = field.split(separator: ",")
195        
196        for part in parts {
197            let partMask = self.parseFieldPart(part, min, max)
198            if partMask == nil {
199                return nil
200            }
201            mask = mask | partMask!
202        }
203        
204        return mask
205    }
206
207    /// Parse individual part of a field (handles -, /, */n, a-b/n)
208    access(contract) fun parseFieldPart(_ part: String, _ min: Int, _ max: Int): UInt64? {
209        if part.contains("/") {
210            let stepParts = part.split(separator: "/")
211            if stepParts.length != 2 {
212                return nil
213            }
214            
215            let stepStr = stepParts[1]
216            let step = self.parseInt(stepStr)
217            if step == nil || step! <= 0 {
218                return nil
219            }
220
221            let rangeStr = stepParts[0]
222            var rangeMask: UInt64 = 0
223            
224            if rangeStr == "*" {
225                rangeMask = self.rangeMask(min, max)
226            } else if rangeStr.contains("-") {
227                let rangeParts = rangeStr.split(separator: "-")
228                if rangeParts.length != 2 {
229                    return nil
230                }
231                let startStr = rangeParts[0]
232                let endStr = rangeParts[1]
233                let start = self.parseInt(startStr)
234                let end = self.parseInt(endStr)
235                if start == nil || end == nil || start! < min || end! > max {
236                    return nil
237                }
238                rangeMask = self.rangeMask(start!, end!)
239            } else {
240                let start = self.parseInt(rangeStr)
241                if start == nil || start! < min || start! > max {
242                    return nil
243                }
244                rangeMask = UInt64(1) << UInt64(start!)
245            }
246
247            // Apply step filter
248            var mask: UInt64 = 0
249            var i = min
250            while i <= max {
251                if (rangeMask & (UInt64(1) << UInt64(i))) != 0 {
252                    // Find the start of the range for step calculation
253                    var rangeStart = min
254                    if rangeStr != "*" && rangeStr.contains("-") {
255                        let rangeParts = rangeStr.split(separator: "-")
256                        let start = self.parseInt(rangeParts[0])
257                        if start != nil {
258                            rangeStart = start!
259                        }
260                    } else if rangeStr != "*" {
261                        let start = self.parseInt(rangeStr)
262                        if start != nil {
263                            rangeStart = start!
264                        }
265                    }
266                    
267                    if (i - rangeStart) % step! == 0 {
268                        mask = mask | (UInt64(1) << UInt64(i))
269                    }
270                }
271                i = i + 1
272            }
273            return mask
274        } else if part.contains("-") {
275            let rangeParts = part.split(separator: "-")
276            if rangeParts.length != 2 {
277                return nil
278            }
279            let startStr = rangeParts[0]
280            let endStr = rangeParts[1]
281            let start = self.parseInt(startStr)
282            let end = self.parseInt(endStr)
283            if start == nil || end == nil || start! < min || end! > max {
284                return nil
285            }
286            return self.rangeMask(start!, end!)
287        } else {
288            let value = self.parseInt(part)
289            if value == nil || value! < min || value! > max {
290                return nil
291            }
292            return UInt64(1) << UInt64(value!)
293        }
294    }
295
296    /// Create bitmask for range [start, end]
297    access(contract) fun rangeMask(_ start: Int, _ end: Int): UInt64 {
298        var mask: UInt64 = 0
299        var i = start
300        while i <= end {
301            mask = mask | (UInt64(1) << UInt64(i))
302            i = i + 1
303        }
304        return mask
305    }
306
307    /// Parse integer from string
308    access(contract) fun parseInt(_ str: String): Int? {
309        if str.length == 0 {
310            return nil
311        }
312        
313        var result = 0
314        var i = 0
315        while i < str.length {
316            let char = str[i]
317            if char >= "0" && char <= "9" {
318                let digit = Int(char.utf8[0]) - Int("0".utf8[0])
319                result = result * 10 + digit
320            } else {
321                return nil
322            }
323            i = i + 1
324        }
325        return result
326    }
327
328    /// Compute allowed day mask combining DOM and DOW per Vixie rule
329    access(contract) fun getAllowedDayMask(_ spec: CronSpec, _ year: Int, _ month: Int, _ daysInMonth: Int): UInt32 {
330        if spec.domIsStar && spec.dowIsStar {
331            // Both are *, all days allowed
332            return self.rangeMask32(1, daysInMonth)
333        } else if spec.domIsStar {
334            // Only DOW matters
335            return self.getDowMask(spec.dowMask, year, month, daysInMonth)
336        } else if spec.dowIsStar {
337            // Only DOM matters, clip to month length
338            return spec.domMask & self.rangeMask32(1, daysInMonth)
339        } else {
340            // Both constrained: DOM OR DOW
341            let domClipped = spec.domMask & self.rangeMask32(1, daysInMonth)
342            let dowMask = self.getDowMask(spec.dowMask, year, month, daysInMonth)
343            return domClipped | dowMask
344        }
345    }
346
347    /// Get DOW mask for given month
348    access(contract) fun getDowMask(_ dowSpec: UInt8, _ year: Int, _ month: Int, _ daysInMonth: Int): UInt32 {
349        var mask: UInt32 = 0
350        var d = 1
351        while d <= daysInMonth {
352            let wd = self.weekday(year: year, month: month, day: d)
353            if (dowSpec & (UInt8(1) << UInt8(wd))) != 0 {
354                mask = mask | (UInt32(1) << UInt32(d))
355            }
356            d = d + 1
357        }
358        return mask
359    }
360
361    /// Create range mask for UInt32
362    access(contract) fun rangeMask32(_ start: Int, _ end: Int): UInt32 {
363        var mask: UInt32 = 0
364        var i = start
365        while i <= end {
366            mask = mask | (UInt32(1) << UInt32(i))
367            i = i + 1
368        }
369        return mask
370    }
371
372    /// Check if bit is set at position
373    access(contract) fun hasBit(mask: UInt64, pos: Int): Bool {
374        return (mask & (UInt64(1) << UInt64(pos))) != 0
375    }
376
377    /// Find next set bit starting from pos (inclusive) up to maxPos
378    access(contract) fun nextSetBit(mask: UInt64, pos: Int, maxPos: Int): Int? {
379        var i = pos
380        while i <= maxPos {
381            if (mask & (UInt64(1) << UInt64(i))) != 0 {
382                return i
383            }
384            i = i + 1
385        }
386        return nil
387    }
388
389    /// Check if year is leap year
390    access(contract) fun isLeap(year: Int): Bool {
391        return (year % 4 == 0 && year % 100 != 0) || (year % 400 == 0)
392    }
393
394    /// Get number of days in month
395    access(contract) fun daysInMonth(year: Int, month: Int): Int {
396        switch month {
397        case 1: return 31
398        case 2: return self.isLeap(year: year) ? 29 : 28
399        case 3: return 31
400        case 4: return 30
401        case 5: return 31
402        case 6: return 30
403        case 7: return 31
404        case 8: return 31
405        case 9: return 30
406        case 10: return 31
407        case 11: return 30
408        case 12: return 31
409        default: return 0
410        }
411    }
412
413    /// Get weekday (0=Sunday, 1=Monday, ..., 6=Saturday)
414    /// Uses Howard Hinnant's weekday_from_days algorithm for O(1) performance
415    /// Reference: https://howardhinnant.github.io/date_algorithms.html
416    access(contract) fun weekday(year: Int, month: Int, day: Int): Int {
417        // Use Hinnant's algorithm via days calculation
418        let daysSinceEpoch = self.daysFromCivil(year: year, month: month, day: day)
419        
420        // Unix epoch (1970-01-01) was a Thursday (4)
421        // Apply modular arithmetic to get weekday
422        let weekdayIndex = (daysSinceEpoch + 4) % 7
423        
424        // Handle negative modulo result
425        return weekdayIndex >= 0 ? weekdayIndex : weekdayIndex + 7
426    }
427
428    /// Convert Unix timestamp to DateTime struct
429    /// Uses Howard Hinnant's civil_from_days algorithm for production robustness
430    /// Reference: https://howardhinnant.github.io/date_algorithms.html
431    access(contract) fun ymdhmFromUnix(t: UInt64): DateTime {
432        let secondsPerDay = 86400
433        let secondsPerHour = 3600
434        let secondsPerMinute = 60
435        
436        // Extract time components
437        let days = Int(t / UInt64(secondsPerDay))
438        let secondsInDay = Int(t % UInt64(secondsPerDay))
439        
440        let hour = secondsInDay / secondsPerHour
441        let minute = (secondsInDay % secondsPerHour) / secondsPerMinute
442        
443        // Convert days since Unix epoch (1970-01-01) to civil date
444        // Using Hinnant's civil_from_days algorithm
445        let civilDate = self.civilFromDays(days: days)
446        
447        return DateTime(
448            year: civilDate["year"]!,
449            month: civilDate["month"]!, 
450            day: civilDate["day"]!,
451            hour: hour,
452            minute: minute
453        )
454    }
455    
456    /// Hinnant's civil_from_days algorithm
457    /// Converts days since Unix epoch (1970-01-01) to civil date (y/m/d)
458    /// Algorithm is exact, handles all edge cases, and runs in O(1) time
459    access(contract) fun civilFromDays(days: Int): {String: Int} {
460        // Shift epoch from 1970-01-01 to 0000-03-01 for easier calculation
461        // This puts leap day at end of year
462        let z = days + 719468  // Days from 0000-03-01 to 1970-01-01
463        
464        // Calculate era (400-year cycle)
465        let era = (z >= 0 ? z : z - 146096) / 146097
466        let doe = z - era * 146097  // Day of era [0, 146096]
467        
468        // Calculate year of era [0, 399]
469        let yoe = (doe - doe/1460 + doe/36524 - doe/146096) / 365
470        
471        // Calculate year
472        let y = yoe + era * 400
473        
474        // Calculate day of year [0, 365]
475        let doy = doe - (365*yoe + yoe/4 - yoe/100)
476        
477        // Calculate month and day
478        let mp = (5*doy + 2) / 153  // Month prime [0, 11]
479        let d = doy - (153*mp + 2) / 5 + 1  // Day [1, 31]
480        let m = mp + (mp < 10 ? 3 : -9)  // Month [1, 12]
481        
482        // Adjust year if month is Jan/Feb (we shifted calendar)
483        let adjustedYear = y + (m <= 2 ? 1 : 0)
484        
485        return {
486            "year": adjustedYear,
487            "month": m,
488            "day": d
489        }
490    }
491
492    /// Convert (year, month, day, hour, minute) to Unix timestamp
493    /// Uses Howard Hinnant's days_from_civil algorithm for production robustness
494    /// Reference: https://howardhinnant.github.io/date_algorithms.html
495    access(contract) fun unixFromYMDHM(y: Int, m: Int, d: Int, h: Int, mi: Int): UInt64 {
496        // Input validation
497        if m < 1 || m > 12 || d < 1 || d > 31 || h < 0 || h > 23 || mi < 0 || mi > 59 {
498            panic("Invalid date/time components")
499        }
500        
501        // Additional day validation for the specific month/year
502        let maxDays = self.daysInMonth(year: y, month: m)
503        if d > maxDays {
504            panic("Day out of range for month")
505        }
506        
507        // Convert civil date to days since Unix epoch using Hinnant's algorithm
508        let daysSinceEpoch = self.daysFromCivil(year: y, month: m, day: d)
509        
510        // Convert to seconds and add time components
511        let totalSeconds = daysSinceEpoch * 86400 + h * 3600 + mi * 60
512        
513        // Ensure we don't return negative timestamps (before Unix epoch)
514        if totalSeconds < 0 {
515            panic("Date is before Unix epoch (1970-01-01)")
516        }
517        
518        return UInt64(totalSeconds)
519    }
520    
521    /// Hinnant's days_from_civil algorithm
522    /// Converts civil date (y/m/d) to days since Unix epoch (1970-01-01)
523    /// Algorithm is exact, handles all edge cases, and runs in O(1) time
524    access(contract) fun daysFromCivil(year: Int, month: Int, day: Int): Int {
525        // Adjust year and month for algorithm (shifts epoch to March 1)
526        let y = year - (month <= 2 ? 1 : 0)
527        let m = month + (month <= 2 ? 12 : 0)
528        
529        // Calculate era (400-year cycle)
530        let era = (y >= 0 ? y : y - 399) / 400
531        
532        // Calculate year of era [0, 399]
533        let yoe = y - era * 400
534        
535        // Calculate day of year [0, 365] (March 1 based)
536        let doy = (153 * (m - 3) + 2) / 5 + day - 1
537        
538        // Calculate day of era [0, 146096]
539        let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy
540        
541        // Calculate days since shifted epoch (0000-03-01)
542        let daysSinceShiftedEpoch = era * 146097 + doe
543        
544        // Convert to days since Unix epoch (1970-01-01)
545        // 719468 is days from 0000-03-01 to 1970-01-01
546        return daysSinceShiftedEpoch - 719468
547    }
548
549    init() {}
550}