Smart Contract
FlowCron
A.6dec6e64a13b881e.FlowCron
1import FlowTransactionScheduler from 0xe467b9dd11fa00df
2import FlowTransactionSchedulerUtils from 0xe467b9dd11fa00df
3import FlowCronUtils from 0x6dec6e64a13b881e
4import FlowToken from 0x1654653399040a61
5import FungibleToken from 0xf233dcee88fe0abe
6import ViewResolver from 0x1d7e57aa55817448
7import MetadataViews from 0x1d7e57aa55817448
8
9/// FlowCron: Wraps any TransactionHandler with cron scheduling.
10///
11/// FEATURES:
12/// - Dual-mode architecture (Keeper/Executor) for fault isolation
13/// - Keeper mode: Pure scheduling logic (only scheduling)
14/// - Executor mode: Runs user code (isolated failures)
15/// - Offset execution: First tick runs both together, subsequent ticks have 1s keeper offset
16/// - User schedules both executor and keeper for first tick
17/// - Standard cron syntax (5-field expressions)
18///
19/// LIFECYCLE:
20/// 1. Create: FlowCron.createCronHandler(expression, wrappedCap)
21/// 2. Bootstrap: User schedules both executor and keeper for next cron tick
22/// 3. Execute: At each cron tick, TWO transactions run:
23/// - Keeper: Schedules next cycle (both keeper + executor)
24/// - Executor: Runs user code
25/// 4. Forever: Perpetual execution at every cron tick
26/// 5. Stop: Cancel all scheduled transactions
27///
28/// FAULT TOLERANCE:
29/// - Executor failure is isolated (emits event, keeper continues)
30/// - Keeper failure panics with detailed error (prevents silent death)
31/// - System survives wrapped handler panics/failures
32access(all) contract FlowCron {
33
34 /// Fixed priority for keeper operations
35 /// Medium priority ensures reliable scheduling without slot filling issues
36 access(all) let keeperPriority: FlowTransactionScheduler.Priority
37 /// Offset in seconds for keeper scheduling relative to executor
38 /// Essential for being scheduled after executor to prevent collision at T+1
39 access(all) let keeperOffset: UInt64
40
41 /// Emitted when keeper successfully schedules next cycle
42 access(all) event CronKeeperExecuted(
43 txID: UInt64,
44 nextExecutorTxID: UInt64?,
45 nextKeeperTxID: UInt64,
46 nextExecutorTime: UInt64?,
47 nextKeeperTime: UInt64,
48 cronExpression: String,
49 handlerUUID: UInt64,
50 wrappedHandlerType: String?,
51 wrappedHandlerUUID: UInt64?
52 )
53
54 /// Emitted when executor successfully completes user code
55 access(all) event CronExecutorExecuted(
56 txID: UInt64,
57 cronExpression: String,
58 handlerUUID: UInt64,
59 wrappedHandlerType: String?,
60 wrappedHandlerUUID: UInt64?
61 )
62
63 /// Emitted when scheduling is rejected (due to duplicate/unauthorized scheduling)
64 access(all) event CronScheduleRejected(
65 txID: UInt64,
66 cronExpression: String,
67 handlerUUID: UInt64,
68 wrappedHandlerType: String?,
69 wrappedHandlerUUID: UInt64?
70 )
71
72 /// Emitted when scheduling fails due to insufficient funds
73 access(all) event CronScheduleFailed(
74 txID: UInt64,
75 executionMode: UInt8,
76 requiredAmount: UFix64,
77 availableAmount: UFix64,
78 cronExpression: String,
79 handlerUUID: UInt64,
80 wrappedHandlerType: String?,
81 wrappedHandlerUUID: UInt64?
82 )
83
84 /// Emitted when fee estimation fails
85 access(all) event CronEstimationFailed(
86 txID: UInt64,
87 executionMode: UInt8,
88 priority: UInt8,
89 executionEffort: UInt64,
90 error: String?,
91 cronExpression: String,
92 handlerUUID: UInt64,
93 wrappedHandlerType: String?,
94 wrappedHandlerUUID: UInt64?
95 )
96
97 /// Execution mode selector for dual-mode handler
98 access(all) enum ExecutionMode: UInt8 {
99 access(all) case Keeper
100 access(all) case Executor
101 }
102
103 /// CronHandler resource wraps any TransactionHandler with fault-tolerant cron scheduling
104 access(all) resource CronHandler: FlowTransactionScheduler.TransactionHandler, ViewResolver.Resolver {
105
106 /// Cron expression for scheduling
107 access(self) let cronExpression: String
108 /// Cron spec for scheduling
109 access(self) let cronSpec: FlowCronUtils.CronSpec
110
111 /// The handler that performs the actual work
112 access(self) let wrappedHandlerCap: Capability<auth(FlowTransactionScheduler.Execute) &{FlowTransactionScheduler.TransactionHandler}>
113 /// Vault capability for fee payments for rescheduling
114 access(self) let feeProviderCap: Capability<auth(FungibleToken.Withdraw) &FlowToken.Vault>
115 /// Scheduler manager capability for rescheduling
116 access(self) let schedulerManagerCap: Capability<auth(FlowTransactionSchedulerUtils.Owner) &{FlowTransactionSchedulerUtils.Manager}>
117
118 /// Next scheduled keeper transaction ID
119 /// - nil: Cron not running (bootstrap required) or restart case
120 /// - non-nil: ID of the NEXT keeper transaction that will execute
121 /// Used to prevent duplicate/unauthorized keeper scheduling
122 access(self) var nextScheduledKeeperID: UInt64?
123
124 /// Next scheduled executor transaction ID
125 /// - nil: No executor scheduled yet
126 /// - non-nil: ID of the NEXT executor transaction that will run user code
127 /// Used for complete cancellation support
128 access(self) var nextScheduledExecutorID: UInt64?
129
130 init(
131 cronExpression: String,
132 wrappedHandlerCap: Capability<auth(FlowTransactionScheduler.Execute) &{FlowTransactionScheduler.TransactionHandler}>,
133 feeProviderCap: Capability<auth(FungibleToken.Withdraw) &FlowToken.Vault>,
134 schedulerManagerCap: Capability<auth(FlowTransactionSchedulerUtils.Owner) &{FlowTransactionSchedulerUtils.Manager}>
135 ) {
136 pre {
137 cronExpression.length > 0: "Cron expression cannot be empty"
138 wrappedHandlerCap.check(): "Invalid wrapped handler capability provided"
139 feeProviderCap.check(): "Invalid fee provider capability"
140 schedulerManagerCap.check(): "Invalid scheduler manager capability"
141 }
142
143 self.cronExpression = cronExpression
144 self.cronSpec = FlowCronUtils.parse(expression: cronExpression) ?? panic("Invalid cron expression: ".concat(cronExpression))
145 self.wrappedHandlerCap = wrappedHandlerCap
146 self.feeProviderCap = feeProviderCap
147 self.schedulerManagerCap = schedulerManagerCap
148 self.nextScheduledKeeperID = nil
149 self.nextScheduledExecutorID = nil
150 }
151
152 /// Main execution entry point for scheduled transactions
153 /// Routes to keeper or executor mode based on context
154 access(FlowTransactionScheduler.Execute) fun executeTransaction(id: UInt64, data: AnyStruct?) {
155 // Parse execution context
156 let context = data as? CronContext ?? panic("Invalid execution data: expected CronContext")
157
158 // Route based on execution mode
159 if context.executionMode == ExecutionMode.Keeper {
160 // Keeper verification: Prevent duplicate/unauthorized keeper scheduling
161 // This ensures only the keeper we scheduled can execute, blocking duplicate schedules while cron is running
162 if let storedID = self.nextScheduledKeeperID {
163 // We have a stored keeper ID, verify this execution matches it
164 if let txData = FlowTransactionScheduler.getTransactionData(id: storedID) {
165 // Data exists so transaction is scheduled, check this is the expected keeper
166 if id != storedID {
167 let wrappedHandler = self.wrappedHandlerCap.borrow()
168 emit CronScheduleRejected(
169 txID: id,
170 cronExpression: self.cronExpression,
171 handlerUUID: self.uuid,
172 wrappedHandlerType: wrappedHandler?.getType()?.identifier,
173 wrappedHandlerUUID: wrappedHandler?.uuid
174 )
175 return
176 }
177 }
178 }
179 // No stored ID or verification passed so execute keeper mode
180 self.executeKeeperMode(txID: id, context: context)
181 } else {
182 // Executor mode with no verification so they run independently without affecting the keeper chain
183 self.executeExecutorMode(txID: id, context: context)
184 }
185 }
186
187 /// Keeper mode: Pure scheduling logic, no user code execution
188 /// Only calculates times and schedules transactions
189 /// Schedules executor at cron tick and keeper with 1s offset (separate slots)
190 access(self) fun executeKeeperMode(txID: UInt64, context: CronContext) {
191 // Calculate next cron tick (for BOTH executor and keeper)
192 let currentTime = UInt64(getCurrentBlock().timestamp)
193 let nextTick = FlowCronUtils.nextTick(spec: self.cronSpec, afterUnix: currentTime) ?? panic("Cannot calculate next cron tick")
194
195 // Schedule executor FIRST at exact cron tick
196 // Returns nil if scheduling fails (only possible with High priority slot full)
197 // No fallback to execute exactly as user meant it to run so that its work is explicit
198 let executorTxID = self.scheduleCronTransaction(
199 txID: txID,
200 executionMode: ExecutionMode.Executor,
201 timestamp: nextTick,
202 priority: context.executorPriority,
203 executionEffort: context.executorExecutionEffort,
204 context: context
205 )
206 // Store executor transaction ID for cancellation support (nil if scheduling failed)
207 self.nextScheduledExecutorID = executorTxID
208
209 // Determine keeper timestamp based on actual executor schedule
210 // For Medium/Low priority, actual scheduled time may differ from requested nextTick
211 // Keeper must run AFTER executor, so use actual executor timestamp + offset
212 var actualExecutorTime: UInt64? = nil
213 var keeperTimestamp = nextTick + FlowCron.keeperOffset
214 if let execID = executorTxID {
215 if let txData = FlowTransactionScheduler.getTransactionData(id: execID) {
216 actualExecutorTime = UInt64(txData.scheduledTimestamp)
217 keeperTimestamp = actualExecutorTime! + FlowCron.keeperOffset
218 }
219 }
220 // Schedule keeper with offset from actual executor time (or nextTick if executor failed)
221 let keeperTxID = self.scheduleCronTransaction(
222 txID: txID,
223 executionMode: ExecutionMode.Keeper,
224 timestamp: keeperTimestamp,
225 priority: FlowCron.keeperPriority,
226 executionEffort: context.keeperExecutionEffort,
227 context: context
228 )!
229 // Store keeper transaction ID to prevent duplicate scheduling
230 self.nextScheduledKeeperID = keeperTxID
231
232 // Emit keeper executed event with actual scheduled times
233 let wrappedHandler = self.wrappedHandlerCap.borrow()
234 emit CronKeeperExecuted(
235 txID: txID,
236 nextExecutorTxID: executorTxID,
237 nextKeeperTxID: keeperTxID,
238 nextExecutorTime: actualExecutorTime,
239 nextKeeperTime: keeperTimestamp,
240 cronExpression: self.cronExpression,
241 handlerUUID: self.uuid,
242 wrappedHandlerType: wrappedHandler?.getType()?.identifier,
243 wrappedHandlerUUID: wrappedHandler?.uuid
244 )
245 }
246
247 /// Executor mode: Runs user's wrapped handler
248 /// Executes arbitrary user code which may panic
249 access(self) fun executeExecutorMode(txID: UInt64, context: CronContext) {
250 // Execute wrapped handler
251 // If this panics, transaction reverts but keeper was already scheduled in a keeper execution
252 let wrappedHandler = self.wrappedHandlerCap.borrow() ?? panic("Cannot borrow wrapped handler capability")
253 wrappedHandler.executeTransaction(id: txID, data: context.wrappedData)
254
255 // Emit completion event
256 emit CronExecutorExecuted(
257 txID: txID,
258 cronExpression: self.cronExpression,
259 handlerUUID: self.uuid,
260 wrappedHandlerType: wrappedHandler.getType().identifier,
261 wrappedHandlerUUID: wrappedHandler.uuid
262 )
263 }
264
265 /// Unified scheduling function with explicit parameters and error handling
266 /// Schedules a cron transaction (keeper or executor) with specified priority
267 access(self) fun scheduleCronTransaction(
268 txID: UInt64,
269 executionMode: ExecutionMode,
270 timestamp: UInt64,
271 priority: FlowTransactionScheduler.Priority,
272 executionEffort: UInt64,
273 context: CronContext
274 ): UInt64? {
275 // Borrow capabilities
276 let schedulerManager = self.schedulerManagerCap.borrow() ?? panic("Cannot borrow scheduler manager")
277 let feeVault = self.feeProviderCap.borrow() ?? panic("Cannot borrow fee provider")
278
279 // Create execution context preserving original executor/keeper config
280 let execContext = CronContext(
281 executionMode: executionMode,
282 executorPriority: context.executorPriority,
283 executorExecutionEffort: context.executorExecutionEffort,
284 keeperExecutionEffort: context.keeperExecutionEffort,
285 wrappedData: context.wrappedData
286 )
287 // Estimate fees
288 let estimate = FlowTransactionScheduler.estimate(
289 data: execContext,
290 timestamp: UFix64(timestamp),
291 priority: priority,
292 executionEffort: executionEffort
293 )
294
295 // Handle estimation result
296 let wrappedHandler = self.wrappedHandlerCap.borrow()
297 if let requiredFee = estimate.flowFee {
298 // Check sufficient balance
299 if feeVault.balance >= requiredFee {
300 // Schedule transaction
301 let fees <- feeVault.withdraw(amount: requiredFee)
302 let txID = schedulerManager.scheduleByHandler(
303 handlerTypeIdentifier: self.getType().identifier,
304 handlerUUID: self.uuid,
305 data: execContext,
306 timestamp: UFix64(timestamp),
307 priority: priority,
308 executionEffort: executionEffort,
309 fees: <-fees as! @FlowToken.Vault
310 )
311 return txID
312 } else {
313 // Insufficient funds, emits event
314 emit CronScheduleFailed(
315 txID: txID,
316 executionMode: executionMode.rawValue,
317 requiredAmount: requiredFee,
318 availableAmount: feeVault.balance,
319 cronExpression: self.cronExpression,
320 handlerUUID: self.uuid,
321 wrappedHandlerType: wrappedHandler?.getType()?.identifier,
322 wrappedHandlerUUID: wrappedHandler?.uuid
323 )
324 return nil
325 }
326 }
327
328 // If we arrive here, estimation failed so emit event and return nil
329 emit CronEstimationFailed(
330 txID: txID,
331 executionMode: executionMode.rawValue,
332 priority: priority.rawValue,
333 executionEffort: executionEffort,
334 error: estimate.error,
335 cronExpression: self.cronExpression,
336 handlerUUID: self.uuid,
337 wrappedHandlerType: wrappedHandler?.getType()?.identifier,
338 wrappedHandlerUUID: wrappedHandler?.uuid
339 )
340 return nil
341 }
342
343 /// Returns the cron expression
344 access(all) view fun getCronExpression(): String {
345 return self.cronExpression
346 }
347
348 /// Returns a copy of the cron spec for use in calculations
349 access(all) view fun getCronSpec(): FlowCronUtils.CronSpec {
350 return self.cronSpec
351 }
352
353 /// Returns the next scheduled keeper transaction ID if one exists
354 access(all) view fun getNextScheduledKeeperID(): UInt64? {
355 return self.nextScheduledKeeperID
356 }
357
358 /// Returns the next scheduled executor transaction ID if one exists
359 access(all) view fun getNextScheduledExecutorID(): UInt64? {
360 return self.nextScheduledExecutorID
361 }
362
363 access(all) view fun getViews(): [Type] {
364 var views: [Type] = [
365 Type<MetadataViews.Display>(),
366 Type<CronInfo>()
367 ]
368
369 // Add wrapped handler views, but deduplicate to avoid collisions
370 if let handler = self.wrappedHandlerCap.borrow() {
371 for viewType in handler.getViews() {
372 if !views.contains(viewType) {
373 views = views.concat([viewType])
374 }
375 }
376 }
377 return views
378 }
379
380 access(all) fun resolveView(_ view: Type): AnyStruct? {
381 let wrappedHandler = self.wrappedHandlerCap.borrow()
382 switch view {
383 case Type<MetadataViews.Display>():
384 // Try to get wrapped handler's display
385 let wrappedDisplay = wrappedHandler?.resolveView(Type<MetadataViews.Display>()) as? MetadataViews.Display
386
387 if let wrapped = wrappedDisplay {
388 // Merge: Enrich wrapped handler's display with cron info
389 return MetadataViews.Display(
390 name: wrapped.name.concat(" (Cron)"),
391 description: wrapped.description
392 .concat(" (Cron: ").concat(self.cronExpression).concat(")"),
393 thumbnail: wrapped.thumbnail
394 )
395 } else {
396 // Fallback: Cron-only display (when wrapped handler doesn't provide display)
397 let handlerType = wrappedHandler?.getType()?.identifier ?? "Unknown"
398 return MetadataViews.Display(
399 name: "Cron Handler",
400 description: "Scheduled handler: ".concat(handlerType)
401 .concat(" (Cron: ").concat(self.cronExpression).concat(")"),
402 thumbnail: MetadataViews.HTTPFile(url: "")
403 )
404 }
405 case Type<CronInfo>():
406 return CronInfo(
407 cronExpression: self.cronExpression,
408 cronSpec: self.cronSpec,
409 nextScheduledKeeperID: self.nextScheduledKeeperID,
410 nextScheduledExecutorID: self.nextScheduledExecutorID,
411 wrappedHandlerType: wrappedHandler?.getType()?.identifier,
412 wrappedHandlerUUID: wrappedHandler?.uuid
413 )
414 default:
415 return wrappedHandler?.resolveView(view)
416 }
417 }
418 }
419
420 /// Context passed to each cron execution
421 access(all) struct CronContext {
422 access(contract) let executionMode: ExecutionMode
423 access(contract) let executorPriority: FlowTransactionScheduler.Priority
424 access(contract) let executorExecutionEffort: UInt64
425 access(contract) let keeperExecutionEffort: UInt64
426 access(contract) let wrappedData: AnyStruct?
427
428 init(
429 executionMode: ExecutionMode,
430 executorPriority: FlowTransactionScheduler.Priority,
431 executorExecutionEffort: UInt64,
432 keeperExecutionEffort: UInt64,
433 wrappedData: AnyStruct?
434 ) {
435 pre {
436 executorExecutionEffort >= 100: "Executor execution effort must be at least 100 (scheduler minimum)"
437 executorExecutionEffort <= 9999: "Executor execution effort must be at most 9999 (scheduler maximum)"
438 keeperExecutionEffort >= 100: "Keeper execution effort must be at least 100 (scheduler minimum)"
439 keeperExecutionEffort <= 9999: "Keeper execution effort must be at most 9999 (scheduler maximum)"
440 }
441
442 self.executionMode = executionMode
443 self.executorPriority = executorPriority
444 self.executorExecutionEffort = executorExecutionEffort
445 self.keeperExecutionEffort = keeperExecutionEffort
446 self.wrappedData = wrappedData
447 }
448 }
449
450 /// View structure exposing cron handler metadata
451 access(all) struct CronInfo {
452 /// The original cron expression string
453 access(all) let cronExpression: String
454 /// Parsed cron specification for execution
455 access(all) let cronSpec: FlowCronUtils.CronSpec
456 /// Next scheduled keeper transaction ID
457 access(all) let nextScheduledKeeperID: UInt64?
458 /// Next scheduled executor transaction ID
459 access(all) let nextScheduledExecutorID: UInt64?
460 /// Type identifier of wrapped handler
461 access(all) let wrappedHandlerType: String?
462 /// UUID of wrapped handler resource
463 access(all) let wrappedHandlerUUID: UInt64?
464
465 init(
466 cronExpression: String,
467 cronSpec: FlowCronUtils.CronSpec,
468 nextScheduledKeeperID: UInt64?,
469 nextScheduledExecutorID: UInt64?,
470 wrappedHandlerType: String?,
471 wrappedHandlerUUID: UInt64?
472 ) {
473 self.cronExpression = cronExpression
474 self.cronSpec = cronSpec
475 self.nextScheduledKeeperID = nextScheduledKeeperID
476 self.nextScheduledExecutorID = nextScheduledExecutorID
477 self.wrappedHandlerType = wrappedHandlerType
478 self.wrappedHandlerUUID = wrappedHandlerUUID
479 }
480 }
481
482 /// Create a new CronHandler resource
483 access(all) fun createCronHandler(
484 cronExpression: String,
485 wrappedHandlerCap: Capability<auth(FlowTransactionScheduler.Execute) &{FlowTransactionScheduler.TransactionHandler}>,
486 feeProviderCap: Capability<auth(FungibleToken.Withdraw) &FlowToken.Vault>,
487 schedulerManagerCap: Capability<auth(FlowTransactionSchedulerUtils.Owner) &{FlowTransactionSchedulerUtils.Manager}>
488 ): @CronHandler {
489 return <- create CronHandler(
490 cronExpression: cronExpression,
491 wrappedHandlerCap: wrappedHandlerCap,
492 feeProviderCap: feeProviderCap,
493 schedulerManagerCap: schedulerManagerCap
494 )
495 }
496
497 init() {
498 // Set fixed medium priority for keeper operations to balance reliability with cost efficiency
499 self.keeperPriority = FlowTransactionScheduler.Priority.Medium
500 // Keeper offset of 1 second to prevent race condition
501 self.keeperOffset = 1
502 }
503}