Smart Contract
FlowCronUtils
A.6dec6e64a13b881e.FlowCronUtils
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}