Smart Contract

FlowCron

A.6dec6e64a13b881e.FlowCron

Valid From

136,510,246

Deployed

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

Dependents

0 imports
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}