Smart Contract
FlowTransactionScheduler
A.e467b9dd11fa00df.FlowTransactionScheduler
1import FungibleToken from 0xf233dcee88fe0abe
2import FlowToken from 0x1654653399040a61
3import FlowFees from 0xf919ee77447b7497
4import FlowStorageFees from 0xe467b9dd11fa00df
5import ViewResolver from 0x1d7e57aa55817448
6
7/// FlowTransactionScheduler enables smart contracts to schedule autonomous execution in the future.
8///
9/// This contract implements FLIP 330's scheduled transaction system, allowing contracts to "wake up" and execute
10/// logic at predefined times without external triggers.
11///
12/// Scheduled transactions are prioritized (High/Medium/Low) with different execution guarantees and fee multipliers:
13/// - High priority guarantees first-block execution,
14/// - Medium priority provides best-effort scheduling,
15/// - Low priority executes opportunistically when capacity allows after the time it was scheduled.
16///
17/// The system uses time slots with execution effort limits to manage network resources,
18/// ensuring predictable performance while enabling novel autonomous blockchain patterns like recurring
19/// payments, automated arbitrage, and time-based contract logic.
20access(all) contract FlowTransactionScheduler {
21
22 /// singleton instance used to store all scheduled transaction data
23 /// and route all scheduled transaction functionality
24 access(self) var sharedScheduler: Capability<auth(Cancel) &SharedScheduler>
25
26 /// storage path for the singleton scheduler resource
27 access(all) let storagePath: StoragePath
28
29 /// Enums
30
31 /// Priority
32 access(all) enum Priority: UInt8 {
33 access(all) case High
34 access(all) case Medium
35 access(all) case Low
36 }
37
38 /// Status
39 access(all) enum Status: UInt8 {
40 /// unknown statuses are used for handling historic scheduled transactions with null statuses
41 access(all) case Unknown
42 /// mutable status
43 access(all) case Scheduled
44 /// finalized statuses
45 access(all) case Executed
46 access(all) case Canceled
47 }
48
49 /// Events
50
51 /// Emitted when a transaction is scheduled
52 access(all) event Scheduled(
53 id: UInt64,
54 priority: UInt8,
55 timestamp: UFix64,
56 executionEffort: UInt64,
57 fees: UFix64,
58 transactionHandlerOwner: Address,
59 transactionHandlerTypeIdentifier: String,
60 transactionHandlerUUID: UInt64,
61
62 // The public path of the transaction handler that can be used to resolve views
63 // DISCLAIMER: There is no guarantee that the public path is accurate
64 transactionHandlerPublicPath: PublicPath?
65 )
66
67 /// Emitted when a scheduled transaction's scheduled timestamp is reached and it is ready for execution
68 access(all) event PendingExecution(
69 id: UInt64,
70 priority: UInt8,
71 executionEffort: UInt64,
72 fees: UFix64,
73 transactionHandlerOwner: Address,
74 transactionHandlerTypeIdentifier: String
75 )
76
77 /// Emitted when a scheduled transaction is executed by the FVM
78 access(all) event Executed(
79 id: UInt64,
80 priority: UInt8,
81 executionEffort: UInt64,
82 transactionHandlerOwner: Address,
83 transactionHandlerTypeIdentifier: String,
84 transactionHandlerUUID: UInt64,
85
86 // The public path of the transaction handler that can be used to resolve views
87 // DISCLAIMER: There is no guarantee that the public path is accurate
88 transactionHandlerPublicPath: PublicPath?
89 )
90
91 /// Emitted when a scheduled transaction is canceled by the creator of the transaction
92 access(all) event Canceled(
93 id: UInt64,
94 priority: UInt8,
95 feesReturned: UFix64,
96 feesDeducted: UFix64,
97 transactionHandlerOwner: Address,
98 transactionHandlerTypeIdentifier: String
99 )
100
101 /// Emitted when a collection limit is reached
102 /// The limit that was reached is non-nil and is the limit that was reached
103 /// The other limit that was not reached is nil
104 access(all) event CollectionLimitReached(
105 collectionEffortLimit: UInt64?,
106 collectionTransactionsLimit: Int?
107 )
108
109 /// Emitted when the limit on the number of transactions that can be removed in process() is reached
110 access(all) event RemovalLimitReached()
111
112 // Emitted when one or more of the configuration details fields are updated
113 // Event listeners can listen to this and query the new configuration
114 // if they need to
115 access(all) event ConfigUpdated()
116
117 // Emitted when a critical issue is encountered
118 access(all) event CriticalIssue(message: String)
119
120 /// Entitlements
121 access(all) entitlement Execute
122 access(all) entitlement Process
123 access(all) entitlement Cancel
124 access(all) entitlement UpdateConfig
125
126 /// Interfaces
127
128 /// TransactionHandler is an interface that defines a single method executeTransaction that
129 /// must be implemented by the resource that contains the logic to be executed by the scheduled transaction.
130 /// An authorized capability to this resource is provided when scheduling a transaction.
131 /// The transaction scheduler uses this capability to execute the transaction when its scheduled timestamp arrives.
132 access(all) resource interface TransactionHandler: ViewResolver.Resolver {
133
134 access(all) view fun getViews(): [Type] {
135 return []
136 }
137
138 access(all) fun resolveView(_ view: Type): AnyStruct? {
139 return nil
140 }
141
142 /// Executes the implemented transaction logic
143 ///
144 /// @param id: The id of the scheduled transaction (this can be useful for any internal tracking)
145 /// @param data: The data that was passed when the transaction was originally scheduled
146 /// that may be useful for the execution of the transaction logic
147 access(Execute) fun executeTransaction(id: UInt64, data: AnyStruct?)
148 }
149
150 /// Structs
151
152 /// ScheduledTransaction is the resource that the user receives after scheduling a transaction.
153 /// It allows them to get the status of their transaction and can be passed back
154 /// to the scheduler contract to cancel the transaction if it has not yet been executed.
155 access(all) resource ScheduledTransaction {
156 access(all) let id: UInt64
157 access(all) let timestamp: UFix64
158 access(all) let handlerTypeIdentifier: String
159
160 access(all) view fun status(): Status? {
161 return FlowTransactionScheduler.sharedScheduler.borrow()!.getStatus(id: self.id)
162 }
163
164 init(
165 id: UInt64,
166 timestamp: UFix64,
167 handlerTypeIdentifier: String
168 ) {
169 self.id = id
170 self.timestamp = timestamp
171 self.handlerTypeIdentifier = handlerTypeIdentifier
172 }
173
174 // event emitted when the resource is destroyed
175 access(all) event ResourceDestroyed(id: UInt64 = self.id, timestamp: UFix64 = self.timestamp, handlerTypeIdentifier: String = self.handlerTypeIdentifier)
176 }
177
178 /// EstimatedScheduledTransaction contains data for estimating transaction scheduling.
179 access(all) struct EstimatedScheduledTransaction {
180 /// flowFee is the estimated fee in Flow for the transaction to be scheduled
181 access(all) let flowFee: UFix64?
182 /// timestamp is estimated timestamp that the transaction will be executed at
183 access(all) let timestamp: UFix64?
184 /// error is an optional error message if the transaction cannot be scheduled
185 access(all) let error: String?
186
187 access(contract) view init(flowFee: UFix64?, timestamp: UFix64?, error: String?) {
188 self.flowFee = flowFee
189 self.timestamp = timestamp
190 self.error = error
191 }
192 }
193
194 /// Transaction data is a representation of a scheduled transaction
195 /// It is the source of truth for an individual transaction and stores the
196 /// capability to the handler that contains the logic that will be executed by the transaction.
197 access(all) struct TransactionData {
198 access(all) let id: UInt64
199 access(all) let priority: Priority
200 access(all) let executionEffort: UInt64
201 access(all) var status: Status
202
203 /// Fee amount to pay for the transaction
204 access(all) let fees: UFix64
205
206 /// The timestamp that the transaction is scheduled for
207 /// For medium priority transactions, it may be different than the requested timestamp
208 /// For low priority transactions, it is the requested timestamp,
209 /// but the timestamp where the transaction is actually executed may be different
210 access(all) var scheduledTimestamp: UFix64
211
212 /// Capability to the logic that the transaction will execute
213 access(contract) let handler: Capability<auth(Execute) &{TransactionHandler}>
214
215 /// Type identifier of the transaction handler
216 access(all) let handlerTypeIdentifier: String
217 access(all) let handlerAddress: Address
218
219 /// Optional data that can be passed to the handler
220 /// This data is publicly accessible, so make sure it does not contain
221 /// any privileged information or functionality
222 access(contract) let data: AnyStruct?
223
224 access(contract) init(
225 id: UInt64,
226 handler: Capability<auth(Execute) &{TransactionHandler}>,
227 scheduledTimestamp: UFix64,
228 data: AnyStruct?,
229 priority: Priority,
230 executionEffort: UInt64,
231 fees: UFix64,
232 ) {
233 self.id = id
234 self.handler = handler
235 self.data = data
236 self.priority = priority
237 self.executionEffort = executionEffort
238 self.fees = fees
239 self.status = Status.Scheduled
240 let handlerRef = handler.borrow()
241 ?? panic("Invalid transaction handler: Could not borrow a reference to the transaction handler")
242 self.handlerAddress = handler.address
243 self.handlerTypeIdentifier = handlerRef.getType().identifier
244 self.scheduledTimestamp = scheduledTimestamp
245 }
246
247 /// setStatus updates the status of the transaction.
248 /// It panics if the transaction status is already finalized.
249 access(contract) fun setStatus(newStatus: Status) {
250 pre {
251 newStatus != Status.Unknown: "Invalid status: New status cannot be Unknown"
252 self.status != Status.Executed && self.status != Status.Canceled:
253 "Invalid status: Transaction with id \(self.id) is already finalized"
254 newStatus == Status.Executed ? self.status == Status.Scheduled : true:
255 "Invalid status: Transaction with id \(self.id) can only be set as Executed if it is Scheduled"
256 newStatus == Status.Canceled ? self.status == Status.Scheduled : true:
257 "Invalid status: Transaction with id \(self.id) can only be set as Canceled if it is Scheduled"
258 }
259
260 self.status = newStatus
261 }
262
263 /// setScheduledTimestamp updates the scheduled timestamp of the transaction.
264 /// It panics if the transaction status is already finalized.
265 access(contract) fun setScheduledTimestamp(newTimestamp: UFix64) {
266 pre {
267 self.status != Status.Executed && self.status != Status.Canceled:
268 "Invalid status: Transaction with id \(self.id) is already finalized"
269 }
270 self.scheduledTimestamp = newTimestamp
271 }
272
273 /// payAndRefundFees withdraws fees from the transaction based on the refund multiplier.
274 /// It deposits any leftover fees to the FlowFees vault to be used to pay node operator rewards
275 /// like any other transaction on the Flow network.
276 access(contract) fun payAndRefundFees(refundMultiplier: UFix64): @FlowToken.Vault {
277 pre {
278 refundMultiplier >= 0.0 && refundMultiplier <= 1.0:
279 "Invalid refund multiplier: The multiplier must be between 0.0 and 1.0 but got \(refundMultiplier)"
280 }
281 if refundMultiplier == 0.0 {
282 FlowFees.deposit(from: <-FlowTransactionScheduler.withdrawFees(amount: self.fees))
283 return <-FlowToken.createEmptyVault(vaultType: Type<@FlowToken.Vault>())
284 } else {
285 let amountToReturn = self.fees * refundMultiplier
286 let amountToKeep = self.fees - amountToReturn
287 let feesToReturn <- FlowTransactionScheduler.withdrawFees(amount: amountToReturn)
288 FlowFees.deposit(from: <-FlowTransactionScheduler.withdrawFees(amount: amountToKeep))
289 return <-feesToReturn
290 }
291 }
292
293 /// getData copies and returns the data field
294 access(all) view fun getData(): AnyStruct? {
295 return self.data
296 }
297
298 /// borrowHandler returns an un-entitled reference to the transaction handler
299 /// This allows users to query metadata views about the handler
300 /// @return: An un-entitled reference to the transaction handler
301 access(all) view fun borrowHandler(): &{TransactionHandler} {
302 return self.handler.borrow() as? &{TransactionHandler}
303 ?? panic("Invalid transaction handler: Could not borrow a reference to the transaction handler")
304 }
305 }
306
307 /// Struct interface representing all the base configuration details in the Scheduler contract
308 /// that is used for governing the protocol
309 /// This is an interface to allow for the configuration details to be updated in the future
310 access(all) struct interface SchedulerConfig {
311
312 /// maximum effort that can be used for any transaction
313 access(all) var maximumIndividualEffort: UInt64
314
315 /// minimum execution effort is the minimum effort that can be
316 /// used for any transaction
317 access(all) var minimumExecutionEffort: UInt64
318
319 /// slot total effort limit is the maximum effort that can be
320 /// cumulatively allocated to one timeslot by all priorities
321 access(all) var slotTotalEffortLimit: UInt64
322
323 /// slot shared effort limit is the maximum effort
324 /// that can be allocated to high and medium priority
325 /// transactions combined after their exclusive effort reserves have been filled
326 access(all) var slotSharedEffortLimit: UInt64
327
328 /// priority effort reserve is the amount of effort that is
329 /// reserved exclusively for each priority
330 access(all) var priorityEffortReserve: {Priority: UInt64}
331
332 /// priority effort limit is the maximum cumulative effort per priority in a timeslot
333 access(all) var priorityEffortLimit: {Priority: UInt64}
334
335 /// max data size is the maximum data size that can be stored for a transaction
336 access(all) var maxDataSizeMB: UFix64
337
338 /// priority fee multipliers are values we use to calculate the added
339 /// processing fee for each priority
340 access(all) var priorityFeeMultipliers: {Priority: UFix64}
341
342 /// refund multiplier is the portion of the fees that are refunded when any transaction is cancelled
343 access(all) var refundMultiplier: UFix64
344
345 /// canceledTransactionsLimit is the maximum number of canceled transactions
346 /// to keep in the canceledTransactions array
347 access(all) var canceledTransactionsLimit: UInt
348
349 /// collectionEffortLimit is the maximum effort that can be used for all transactions in a collection
350 access(all) var collectionEffortLimit: UInt64
351
352 /// collectionTransactionsLimit is the maximum number of transactions that can be processed in a collection
353 access(all) var collectionTransactionsLimit: Int
354
355 access(all) init(
356 maximumIndividualEffort: UInt64,
357 minimumExecutionEffort: UInt64,
358 slotSharedEffortLimit: UInt64,
359 priorityEffortReserve: {Priority: UInt64},
360 lowPriorityEffortLimit: UInt64,
361 maxDataSizeMB: UFix64,
362 priorityFeeMultipliers: {Priority: UFix64},
363 refundMultiplier: UFix64,
364 canceledTransactionsLimit: UInt,
365 collectionEffortLimit: UInt64,
366 collectionTransactionsLimit: Int,
367 txRemovalLimit: UInt
368 ) {
369 post {
370 self.refundMultiplier >= 0.0 && self.refundMultiplier <= 1.0:
371 "Invalid refund multiplier: The multiplier must be between 0.0 and 1.0 but got \(refundMultiplier)"
372 self.priorityFeeMultipliers[Priority.Low]! >= 1.0:
373 "Invalid priority fee multiplier: Low priority multiplier must be greater than or equal to 1.0 but got \(self.priorityFeeMultipliers[Priority.Low]!)"
374 self.priorityFeeMultipliers[Priority.Medium]! > self.priorityFeeMultipliers[Priority.Low]!:
375 "Invalid priority fee multiplier: Medium priority multiplier must be greater than or equal to \(priorityFeeMultipliers[Priority.Low]!) but got \(priorityFeeMultipliers[Priority.Medium]!)"
376 self.priorityFeeMultipliers[Priority.High]! > self.priorityFeeMultipliers[Priority.Medium]!:
377 "Invalid priority fee multiplier: High priority multiplier must be greater than or equal to \(priorityFeeMultipliers[Priority.Medium]!) but got \(priorityFeeMultipliers[Priority.High]!)"
378 self.priorityEffortLimit[Priority.High]! >= self.priorityEffortReserve[Priority.High]!:
379 "Invalid priority effort limit: High priority effort limit must be greater than or equal to the priority effort reserve of \(priorityEffortReserve[Priority.High]!)"
380 self.priorityEffortLimit[Priority.Medium]! >= self.priorityEffortReserve[Priority.Medium]!:
381 "Invalid priority effort limit: Medium priority effort limit must be greater than or equal to the priority effort reserve of \(priorityEffortReserve[Priority.Medium]!)"
382 self.priorityEffortLimit[Priority.Low]! >= self.priorityEffortReserve[Priority.Low]!:
383 "Invalid priority effort limit: Low priority effort limit must be greater than or equal to the priority effort reserve of \(priorityEffortReserve[Priority.Low]!)"
384 self.priorityEffortReserve[Priority.Low]! == 0:
385 "Invalid priority effort reserve: Low priority effort reserve must be 0"
386 self.collectionTransactionsLimit >= 0:
387 "Invalid collection transactions limit: Collection transactions limit must be greater than or equal to 0 but got \(collectionTransactionsLimit)"
388 self.canceledTransactionsLimit >= 1:
389 "Invalid canceled transactions limit: Canceled transactions limit must be greater than or equal to 1 but got \(canceledTransactionsLimit)"
390 self.collectionEffortLimit > self.slotTotalEffortLimit:
391 "Invalid collection effort limit: Collection effort limit must be greater than \(self.slotTotalEffortLimit) but got \(self.collectionEffortLimit)"
392 }
393 }
394
395 access(all) view fun getTxRemovalLimit(): UInt
396 }
397
398 /// Concrete implementation of the SchedulerConfig interface
399 /// This struct is used to store the configuration details in the Scheduler contract
400 access(all) struct Config: SchedulerConfig {
401 access(all) var maximumIndividualEffort: UInt64
402 access(all) var minimumExecutionEffort: UInt64
403 access(all) var slotTotalEffortLimit: UInt64
404 access(all) var slotSharedEffortLimit: UInt64
405 access(all) var priorityEffortReserve: {Priority: UInt64}
406 access(all) var priorityEffortLimit: {Priority: UInt64}
407 access(all) var maxDataSizeMB: UFix64
408 access(all) var priorityFeeMultipliers: {Priority: UFix64}
409 access(all) var refundMultiplier: UFix64
410 access(all) var canceledTransactionsLimit: UInt
411 access(all) var collectionEffortLimit: UInt64
412 access(all) var collectionTransactionsLimit: Int
413
414 access(all) init(
415 maximumIndividualEffort: UInt64,
416 minimumExecutionEffort: UInt64,
417 slotSharedEffortLimit: UInt64,
418 priorityEffortReserve: {Priority: UInt64},
419 lowPriorityEffortLimit: UInt64,
420 maxDataSizeMB: UFix64,
421 priorityFeeMultipliers: {Priority: UFix64},
422 refundMultiplier: UFix64,
423 canceledTransactionsLimit: UInt,
424 collectionEffortLimit: UInt64,
425 collectionTransactionsLimit: Int,
426 txRemovalLimit: UInt
427 ) {
428 self.maximumIndividualEffort = maximumIndividualEffort
429 self.minimumExecutionEffort = minimumExecutionEffort
430 self.slotTotalEffortLimit = slotSharedEffortLimit + priorityEffortReserve[Priority.High]! + priorityEffortReserve[Priority.Medium]!
431 self.slotSharedEffortLimit = slotSharedEffortLimit
432 self.priorityEffortReserve = priorityEffortReserve
433 self.priorityEffortLimit = {
434 Priority.High: priorityEffortReserve[Priority.High]! + slotSharedEffortLimit,
435 Priority.Medium: priorityEffortReserve[Priority.Medium]! + slotSharedEffortLimit,
436 Priority.Low: lowPriorityEffortLimit
437 }
438 self.maxDataSizeMB = maxDataSizeMB
439 self.priorityFeeMultipliers = priorityFeeMultipliers
440 self.refundMultiplier = refundMultiplier
441 self.canceledTransactionsLimit = canceledTransactionsLimit
442 self.collectionEffortLimit = collectionEffortLimit
443 self.collectionTransactionsLimit = collectionTransactionsLimit
444 }
445
446 access(all) view fun getTxRemovalLimit(): UInt {
447 return FlowTransactionScheduler.account.storage.copy<UInt>(from: /storage/txRemovalLimit)
448 ?? 200
449 }
450 }
451
452
453 /// SortedTimestamps maintains timestamps sorted in ascending order for efficient processing
454 /// It encapsulates all operations related to maintaining and querying sorted timestamps
455 access(all) struct SortedTimestamps {
456 /// Internal sorted array of timestamps
457 access(self) var timestamps: [UFix64]
458
459 access(all) init() {
460 self.timestamps = []
461 }
462
463 /// bisect is a function that finds the index to insert a new timestamp in the sorted array.
464 /// taken from bisect_right in pthon https://stackoverflow.com/questions/2945017/javas-equivalent-to-bisect-in-python
465 /// @param new: The new timestamp to insert
466 /// @return: The index to insert the new timestamp at or nil if the timestamp is already in the array
467 access(all) fun bisect(new: UFix64): Int? {
468 var high = self.timestamps.length
469 var low = 0
470 while low < high {
471 let mid = (low+high)/2
472 let midTimestamp = self.timestamps[mid]
473
474 if midTimestamp == new {
475 return nil
476 } else if midTimestamp > new {
477 high = mid
478 } else {
479 low = mid + 1
480 }
481 }
482 return low
483 }
484
485 /// Add a timestamp to the sorted array maintaining sorted order
486 access(all) fun add(timestamp: UFix64) {
487 // Only insert if the timestamp is not already in the array
488 if let insertIndex = self.bisect(new: timestamp) {
489 self.timestamps.insert(at: insertIndex, timestamp)
490 }
491 }
492
493 /// Remove a timestamp from the sorted array
494 access(all) fun remove(timestamp: UFix64) {
495 // Only remove if the timestamp is in the array
496 if let index = self.timestamps.firstIndex(of: timestamp) {
497 self.timestamps.remove(at: index)
498 }
499 }
500
501 /// Get all timestamps that are in the past (less than or equal to current timestamp)
502 access(all) fun getBefore(current: UFix64): [UFix64] {
503 let pastTimestamps: [UFix64] = []
504 for timestamp in self.timestamps {
505 if timestamp <= current {
506 pastTimestamps.append(timestamp)
507 } else {
508 break // No need to check further since array is sorted
509 }
510 }
511 return pastTimestamps
512 }
513
514 /// Check if there are any timestamps that need processing
515 /// Returns true if processing is needed, false for early exit
516 access(all) fun hasBefore(current: UFix64): Bool {
517 return self.timestamps.length > 0 && self.timestamps[0] <= current
518 }
519
520 /// Get the whole array of timestamps
521 access(all) fun getAll(): [UFix64] {
522 return self.timestamps
523 }
524 }
525
526 /// Resources
527
528 /// Shared scheduler is a resource that is used as a singleton in the scheduler contract and contains
529 /// all the functionality to schedule, process and execute transactions as well as the internal state.
530 access(all) resource SharedScheduler {
531 /// nextID contains the next transaction ID to be assigned
532 /// This the ID is monotonically increasing and is used to identify each transaction
533 access(contract) var nextID: UInt64
534
535 /// transactions is a map of transaction IDs to TransactionData structs
536 access(contract) var transactions: {UInt64: TransactionData}
537
538 /// slot queue is a map of timestamps to Priorities to transaction IDs and their execution efforts
539 access(contract) var slotQueue: {UFix64: {Priority: {UInt64: UInt64}}}
540
541 /// slot used effort is a map of timestamps map of priorities and
542 /// efforts that has been used for the timeslot
543 access(contract) var slotUsedEffort: {UFix64: {Priority: UInt64}}
544
545 /// sorted timestamps manager for efficient processing
546 access(contract) var sortedTimestamps: SortedTimestamps
547
548 /// canceled transactions keeps a record of canceled transaction IDs up to a canceledTransactionsLimit
549 access(contract) var canceledTransactions: [UInt64]
550
551 /// Struct that contains all the configuration details for the transaction scheduler protocol
552 /// Can be updated by the owner of the contract
553 access(contract) var config: {SchedulerConfig}
554
555 access(all) init() {
556 self.nextID = 1
557 self.canceledTransactions = [0 as UInt64]
558
559 self.transactions = {}
560 self.slotUsedEffort = {}
561 self.slotQueue = {}
562 self.sortedTimestamps = SortedTimestamps()
563
564 /* Default slot efforts and limits look like this:
565
566 Timestamp Slot (17.5kee)
567 ┌─────────────────────────┐
568 │ ┌─────────────┐ │
569 │ │ High Only │ │ High: 15kee max
570 │ │ 10kee │ │ (10 exclusive + 5 shared)
571 │ └─────────────┘ │
572 | ┌───────────────┐ |
573 │ | Shared Pool │ |
574 | │ (High+Medium) │ |
575 | │ 5kee │ |
576 | └───────────────┘ |
577 │ ┌─────────────┐ │ Medium: 7.5kee max
578 │ │ Medium Only │ │ (2.5 exclusive + 5 shared)
579 │ │ 2.5kee │ │
580 │ └─────────────┘ │
581 │ ┌─────────────────────┐ │ Low: 2.5kee max
582 │ │ Low (if space left) │ │ (execution time only)
583 │ │ 2.5kee │ │
584 │ └─────────────────────┘ │
585 └─────────────────────────┘
586 */
587
588 let sharedEffortLimit: UInt64 = 5_000
589 let highPriorityEffortReserve: UInt64 = 10_000
590 let mediumPriorityEffortReserve: UInt64 = 2_500
591
592 self.config = Config(
593 maximumIndividualEffort: 9999,
594 minimumExecutionEffort: 100,
595 slotSharedEffortLimit: sharedEffortLimit,
596 priorityEffortReserve: {
597 Priority.High: highPriorityEffortReserve,
598 Priority.Medium: mediumPriorityEffortReserve,
599 Priority.Low: 0
600 },
601 lowPriorityEffortLimit: 2_500,
602 maxDataSizeMB: 0.001,
603 priorityFeeMultipliers: {
604 Priority.High: 10.0,
605 Priority.Medium: 5.0,
606 Priority.Low: 2.0
607 },
608 refundMultiplier: 0.5,
609 canceledTransactionsLimit: 1000,
610 collectionEffortLimit: 500_000, // Maximum effort for all transactions in a collection
611 collectionTransactionsLimit: 150, // Maximum number of transactions in a collection
612 txRemovalLimit: 200
613 )
614 }
615
616 /// Gets a copy of the struct containing all the configuration details
617 /// of the Scheduler resource
618 access(contract) view fun getConfig(): {SchedulerConfig} {
619 return self.config
620 }
621
622 /// sets all the configuration details for the Scheduler resource
623 access(UpdateConfig) fun setConfig(newConfig: {SchedulerConfig}, txRemovalLimit: UInt) {
624 self.config = newConfig
625 FlowTransactionScheduler.account.storage.load<UInt>(from: /storage/txRemovalLimit)
626 FlowTransactionScheduler.account.storage.save(txRemovalLimit, to: /storage/txRemovalLimit)
627 emit ConfigUpdated()
628 }
629
630 /// getTransaction returns a copy of the specified transaction
631 access(contract) view fun getTransaction(id: UInt64): TransactionData? {
632 return self.transactions[id]
633 }
634
635 /// borrowTransaction borrows a reference to the specified transaction
636 access(contract) view fun borrowTransaction(id: UInt64): &TransactionData? {
637 return &self.transactions[id]
638 }
639
640 /// getCanceledTransactions returns a copy of the canceled transactions array
641 access(contract) view fun getCanceledTransactions(): [UInt64] {
642 return self.canceledTransactions
643 }
644
645 /// getTransactionsForTimeframe returns a dictionary of transactions scheduled within a specified time range,
646 /// organized by timestamp and priority with arrays of transaction IDs.
647 /// WARNING: If you provide a time range that is too large, the function will likely fail to complete
648 /// because the function will run out of gas. Keep the time range small.
649 ///
650 /// @param startTimestamp: The start timestamp (inclusive) for the time range
651 /// @param endTimestamp: The end timestamp (inclusive) for the time range
652 /// @return {UFix64: {Priority: [UInt64]}}: A dictionary mapping timestamps to priorities to arrays of transaction IDs
653 access(contract) fun getTransactionsForTimeframe(startTimestamp: UFix64, endTimestamp: UFix64): {UFix64: {UInt8: [UInt64]}} {
654 var transactionsInTimeframe: {UFix64: {UInt8: [UInt64]}} = {}
655
656 // Validate input parameters
657 if startTimestamp > endTimestamp {
658 return transactionsInTimeframe
659 }
660
661 // Get all timestamps that fall within the specified range
662 let allTimestampsBeforeEnd = self.sortedTimestamps.getBefore(current: endTimestamp)
663
664 for timestamp in allTimestampsBeforeEnd {
665 // Check if this timestamp falls within our range
666 if timestamp < startTimestamp { continue }
667
668 let transactionPriorities = self.slotQueue[timestamp] ?? {}
669
670 var timestampTransactions: {UInt8: [UInt64]} = {}
671
672 for priority in transactionPriorities.keys {
673 let transactionIDs = transactionPriorities[priority] ?? {}
674 var priorityTransactions: [UInt64] = []
675
676 for id in transactionIDs.keys {
677 priorityTransactions.append(id)
678 }
679
680 if priorityTransactions.length > 0 {
681 timestampTransactions[priority.rawValue] = priorityTransactions
682 }
683 }
684
685 if timestampTransactions.keys.length > 0 {
686 transactionsInTimeframe[timestamp] = timestampTransactions
687 }
688
689 }
690
691 return transactionsInTimeframe
692 }
693
694 /// calculate fee by converting execution effort to a fee in Flow tokens.
695 /// @param executionEffort: The execution effort of the transaction
696 /// @param priority: The priority of the transaction
697 /// @param dataSizeMB: The size of the data that was passed when the transaction was originally scheduled
698 /// @return UFix64: The fee in Flow tokens that is required to pay for the transaction
699 access(contract) fun calculateFee(executionEffort: UInt64, priority: Priority, dataSizeMB: UFix64): UFix64 {
700 // Use the official FlowFees calculation
701 let baseFee = FlowFees.computeFees(inclusionEffort: 1.0, executionEffort: UFix64(executionEffort)/100000000.0)
702
703 // Scale the execution fee by the multiplier for the priority
704 let scaledExecutionFee = baseFee * self.config.priorityFeeMultipliers[priority]!
705
706 // Calculate the FLOW required to pay for storage of the transaction data
707 let storageFee = FlowStorageFees.storageCapacityToFlow(dataSizeMB)
708
709 // Add inclusion Flow fee for scheduled transactions
710 let inclusionFee = 0.00001
711
712 return scaledExecutionFee + storageFee + inclusionFee
713 }
714
715 /// getNextIDAndIncrement returns the next ID and increments the ID counter
716 access(self) fun getNextIDAndIncrement(): UInt64 {
717 let nextID = self.nextID
718 self.nextID = self.nextID + 1
719 return nextID
720 }
721
722 /// get status of the scheduled transaction
723 /// @param id: The ID of the transaction to get the status of
724 /// @return Status: The status of the transaction, if the transaction is not found Unknown is returned.
725 access(contract) view fun getStatus(id: UInt64): Status? {
726 // if the transaction ID is greater than the next ID, it is not scheduled yet and has never existed
727 if id == 0 as UInt64 || id >= self.nextID {
728 return nil
729 }
730
731 // This should always return Scheduled or Executed
732 if let tx = self.borrowTransaction(id: id) {
733 return tx.status
734 }
735
736 // if the transaction was canceled and it is still not pruned from
737 // list return canceled status
738 if self.canceledTransactions.contains(id) {
739 return Status.Canceled
740 }
741
742 // if transaction ID is after first canceled ID it must be executed
743 // otherwise it would have been canceled and part of this list
744 let firstCanceledID = self.canceledTransactions[0]
745 if id > firstCanceledID {
746 return Status.Executed
747 }
748
749 // the transaction list was pruned and the transaction status might be
750 // either canceled or execute so we return unknown
751 return Status.Unknown
752 }
753
754 /// schedule is the primary entry point for scheduling a new transaction within the scheduler contract.
755 /// If scheduling the transaction is not possible either due to invalid arguments or due to
756 /// unavailable slots, the function panics.
757 //
758 /// The schedule function accepts the following arguments:
759 /// @param: transaction: A capability to a resource in storage that implements the transaction handler
760 /// interface. This handler will be invoked at execution time and will receive the specified data payload.
761 /// @param: timestamp: Specifies the earliest block timestamp at which the transaction is eligible for execution
762 /// (Unix timestamp so fractional seconds values are ignored). It must be set in the future.
763 /// @param: priority: An enum value (`High`, `Medium`, or `Low`) that influences the scheduling behavior and determines
764 /// how soon after the timestamp the transaction will be executed.
765 /// @param: executionEffort: Defines the maximum computational resources allocated to the transaction. This also determines
766 /// the fee charged. Unused execution effort is not refunded.
767 /// @param: fees: A Vault resource containing sufficient funds to cover the required execution effort.
768 access(contract) fun schedule(
769 handlerCap: Capability<auth(Execute) &{TransactionHandler}>,
770 data: AnyStruct?,
771 timestamp: UFix64,
772 priority: Priority,
773 executionEffort: UInt64,
774 fees: @FlowToken.Vault
775 ): @ScheduledTransaction {
776
777 // Use the estimate function to validate inputs
778 let estimate = self.estimate(
779 data: data,
780 timestamp: timestamp,
781 priority: priority,
782 executionEffort: executionEffort
783 )
784
785 // Estimate returns an error for low priority transactions
786 // so need to check that the error is fine
787 // because low priority transactions are allowed in schedule
788 if estimate.error != nil && estimate.timestamp == nil {
789 panic(estimate.error!)
790 }
791
792 assert (
793 fees.balance >= estimate.flowFee!,
794 message: "Insufficient fees: The Fee balance of \(fees.balance) is not sufficient to pay the required amount of \(estimate.flowFee!) for execution of the transaction."
795 )
796
797 let transactionID = self.getNextIDAndIncrement()
798 let transactionData = TransactionData(
799 id: transactionID,
800 handler: handlerCap,
801 scheduledTimestamp: estimate.timestamp!,
802 data: data,
803 priority: priority,
804 executionEffort: executionEffort,
805 fees: fees.balance,
806 )
807
808 // Deposit the fees to the service account's vault
809 FlowTransactionScheduler.depositFees(from: <-fees)
810
811 let handlerRef = handlerCap.borrow()
812 ?? panic("Invalid transaction handler: Could not borrow a reference to the transaction handler")
813
814 let handlerPublicPath = handlerRef.resolveView(Type<PublicPath>()) as? PublicPath
815
816 emit Scheduled(
817 id: transactionData.id,
818 priority: transactionData.priority.rawValue,
819 timestamp: transactionData.scheduledTimestamp,
820 executionEffort: transactionData.executionEffort,
821 fees: transactionData.fees,
822 transactionHandlerOwner: transactionData.handler.address,
823 transactionHandlerTypeIdentifier: transactionData.handlerTypeIdentifier,
824 transactionHandlerUUID: handlerRef.uuid,
825 transactionHandlerPublicPath: handlerPublicPath
826 )
827
828 // Add the transaction to the slot queue and update the internal state
829 self.addTransaction(slot: estimate.timestamp!, txData: transactionData)
830
831 return <-create ScheduledTransaction(
832 id: transactionID,
833 timestamp: estimate.timestamp!,
834 handlerTypeIdentifier: transactionData.handlerTypeIdentifier
835 )
836 }
837
838 /// The estimate function calculates the required fee in Flow and expected execution timestamp for
839 /// a transaction based on the requested timestamp, priority, and execution effort.
840 //
841 /// If the provided arguments are invalid or the transaction cannot be scheduled (e.g., due to
842 /// insufficient computation effort or unavailable time slots) the estimate function
843 /// returns an EstimatedScheduledTransaction struct with a non-nil error message.
844 ///
845 /// This helps developers ensure sufficient funding and preview the expected scheduling window,
846 /// reducing the risk of unnecessary cancellations.
847 ///
848 /// @param data: The data that was passed when the transaction was originally scheduled
849 /// @param timestamp: The requested timestamp for the transaction
850 /// @param priority: The priority of the transaction
851 /// @param executionEffort: The execution effort of the transaction
852 /// @return EstimatedScheduledTransaction: A struct containing the estimated fee, timestamp, and error message
853 access(contract) fun estimate(
854 data: AnyStruct?,
855 timestamp: UFix64,
856 priority: Priority,
857 executionEffort: UInt64
858 ): EstimatedScheduledTransaction {
859 // Remove fractional values from the timestamp
860 let sanitizedTimestamp = UFix64(UInt64(timestamp))
861
862 if sanitizedTimestamp <= getCurrentBlock().timestamp {
863 return EstimatedScheduledTransaction(
864 flowFee: nil,
865 timestamp: nil,
866 error: "Invalid timestamp: \(sanitizedTimestamp) is in the past, current timestamp: \(getCurrentBlock().timestamp)"
867 )
868 }
869
870 if executionEffort > self.config.maximumIndividualEffort {
871 return EstimatedScheduledTransaction(
872 flowFee: nil,
873 timestamp: nil,
874 error: "Invalid execution effort: \(executionEffort) is greater than the maximum transaction effort of \(self.config.maximumIndividualEffort)"
875 )
876 }
877
878 if executionEffort > self.config.priorityEffortLimit[priority]! {
879 return EstimatedScheduledTransaction(
880 flowFee: nil,
881 timestamp: nil,
882 error: "Invalid execution effort: \(executionEffort) is greater than the priority's max effort of \(self.config.priorityEffortLimit[priority]!)"
883 )
884 }
885
886 if executionEffort < self.config.minimumExecutionEffort {
887 return EstimatedScheduledTransaction(
888 flowFee: nil,
889 timestamp: nil,
890 error: "Invalid execution effort: \(executionEffort) is less than the minimum execution effort of \(self.config.minimumExecutionEffort)"
891 )
892 }
893
894 let dataSizeMB = FlowTransactionScheduler.getSizeOfData(data)
895 if dataSizeMB > self.config.maxDataSizeMB {
896 return EstimatedScheduledTransaction(
897 flowFee: nil,
898 timestamp: nil,
899 error: "Invalid data size: \(dataSizeMB) is greater than the maximum data size of \(self.config.maxDataSizeMB)MB"
900 )
901 }
902
903 let fee = self.calculateFee(executionEffort: executionEffort, priority: priority, dataSizeMB: dataSizeMB)
904
905 let scheduledTimestamp = self.calculateScheduledTimestamp(
906 timestamp: sanitizedTimestamp,
907 priority: priority,
908 executionEffort: executionEffort
909 )
910
911 if scheduledTimestamp == nil {
912 return EstimatedScheduledTransaction(
913 flowFee: nil,
914 timestamp: nil,
915 error: "Invalid execution effort: \(executionEffort) is greater than the priority's available effort for the requested timestamp."
916 )
917 }
918
919 if priority == Priority.Low {
920 return EstimatedScheduledTransaction(
921 flowFee: fee,
922 timestamp: scheduledTimestamp,
923 error: "Invalid Priority: Cannot estimate for Low Priority transactions. They will be included in the first block with available space after their requested timestamp."
924 )
925 }
926
927 return EstimatedScheduledTransaction(flowFee: fee, timestamp: scheduledTimestamp, error: nil)
928 }
929
930 /// calculateScheduledTimestamp calculates the timestamp at which a transaction
931 /// can be scheduled. It takes into account the priority of the transaction and
932 /// the execution effort.
933 /// - If the transaction is high priority, it returns the timestamp if there is enough
934 /// space or nil if there is no space left.
935 /// - If the transaction is medium or low priority and there is space left in the requested timestamp,
936 /// it returns the requested timestamp. If there is not enough space, it finds the next timestamp with space.
937 ///
938 /// @param timestamp: The requested timestamp for the transaction
939 /// @param priority: The priority of the transaction
940 /// @param executionEffort: The execution effort of the transaction
941 /// @return UFix64?: The timestamp at which the transaction can be scheduled, or nil if there is no space left for a high priority transaction
942 access(contract) view fun calculateScheduledTimestamp(
943 timestamp: UFix64,
944 priority: Priority,
945 executionEffort: UInt64
946 ): UFix64? {
947
948 var timestampToSearch = timestamp
949
950 // If no available timestamps are found, this will eventually reach the gas limit and fail
951 // This is extremely unlikely
952 while true {
953
954 let used = self.slotUsedEffort[timestampToSearch]
955 // if nothing is scheduled at this timestamp, we can schedule at provided timestamp
956 if used == nil {
957 return timestampToSearch
958 }
959
960 let available = self.getSlotAvailableEffort(sanitizedTimestamp: timestampToSearch, priority: priority)
961 // if theres enough space, we can tentatively schedule at provided timestamp
962 if executionEffort <= available {
963 return timestampToSearch
964 }
965
966 if priority == Priority.High {
967 // high priority demands scheduling at exact timestamp or failing
968 return nil
969 }
970
971 timestampToSearch = timestampToSearch + 1.0
972 }
973
974 // should never happen
975 return nil
976 }
977
978 /// slot available effort returns the amount of effort that is available for a given timestamp and priority.
979 /// @param sanitizedTimestamp: The timestamp to get the available effort for. It should already have been sanitized
980 /// in the calling function
981 /// @param priority: The priority to get the available effort for
982 /// @return UInt64: The amount of effort that is available for the given timestamp and priority
983 access(contract) view fun getSlotAvailableEffort(sanitizedTimestamp: UFix64, priority: Priority): UInt64 {
984
985 // Get the theoretical maximum allowed for the priority including shared
986 let priorityLimit = self.config.priorityEffortLimit[priority]!
987
988 // If nothing has been claimed for the requested timestamp,
989 // return the full amount
990 if !self.slotUsedEffort.containsKey(sanitizedTimestamp) {
991 return priorityLimit
992 }
993
994 // Get the mapping of how much effort has been used
995 // for each priority for the timestamp
996 let slotPriorityEffortsUsed = &self.slotUsedEffort[sanitizedTimestamp]! as &{Priority: UInt64}
997
998 // Get how much effort has been used for each priority
999 let highUsed = slotPriorityEffortsUsed[Priority.High] ?? 0
1000 let mediumUsed = slotPriorityEffortsUsed[Priority.Medium] ?? 0
1001
1002 // If it is low priority, return whatever effort is remaining
1003 // under the low priority effort limit, subtracting the currently used effort for low priority
1004 if priority == Priority.Low {
1005 let highPlusMediumUsed = highUsed + mediumUsed
1006 let totalEffortRemaining = self.config.slotTotalEffortLimit.saturatingSubtract(highPlusMediumUsed)
1007 let lowEffortRemaining = totalEffortRemaining < priorityLimit ? totalEffortRemaining : priorityLimit
1008 let lowUsed = slotPriorityEffortsUsed[Priority.Low] ?? 0
1009 return lowEffortRemaining.saturatingSubtract(lowUsed)
1010 }
1011
1012 // Get the exclusive reserves for each priority
1013 let highReserve = self.config.priorityEffortReserve[Priority.High]!
1014 let mediumReserve = self.config.priorityEffortReserve[Priority.Medium]!
1015
1016 // Get how much shared effort has been used for each priority
1017 // Ensure the results are always zero or positive
1018 let highSharedUsed: UInt64 = highUsed.saturatingSubtract(highReserve)
1019 let mediumSharedUsed: UInt64 = mediumUsed.saturatingSubtract(mediumReserve)
1020
1021 // Get the theoretical total shared amount between priorities
1022 let totalShared = (self.config.slotTotalEffortLimit.saturatingSubtract(highReserve)).saturatingSubtract(mediumReserve)
1023
1024 // Get the amount of shared effort currently available
1025 let highPlusMediumSharedUsed = highSharedUsed + mediumSharedUsed
1026 // prevent underflow
1027 let sharedAvailable = totalShared.saturatingSubtract(highPlusMediumSharedUsed)
1028
1029 // we calculate available by calculating available shared effort and
1030 // adding any unused reserves for that priority
1031 let reserve = self.config.priorityEffortReserve[priority]!
1032 let used = slotPriorityEffortsUsed[priority] ?? 0
1033 let unusedReserve: UInt64 = reserve.saturatingSubtract(used)
1034 let available = sharedAvailable + unusedReserve
1035
1036 return available
1037 }
1038
1039 /// add transaction to the queue and updates all the internal state as well as emit an event
1040 access(self) fun addTransaction(slot: UFix64, txData: TransactionData) {
1041
1042 // If nothing is in the queue for this slot, initialize the slot
1043 if self.slotQueue[slot] == nil {
1044 self.slotQueue[slot] = {}
1045
1046 // This also means that the used effort record for this slot has not been initialized
1047 self.slotUsedEffort[slot] = {
1048 Priority.High: 0,
1049 Priority.Medium: 0,
1050 Priority.Low: 0
1051 }
1052
1053 self.sortedTimestamps.add(timestamp: slot)
1054 }
1055
1056 // Add this transaction id to the slot
1057 let transactionsForSlot = self.slotQueue[slot]!
1058 if let priorityQueue = transactionsForSlot[txData.priority] {
1059 priorityQueue[txData.id] = txData.executionEffort
1060 transactionsForSlot[txData.priority] = priorityQueue
1061 } else {
1062 transactionsForSlot[txData.priority] = {
1063 txData.id: txData.executionEffort
1064 }
1065 }
1066 self.slotQueue[slot] = transactionsForSlot
1067
1068 // Add the execution effort for this transaction to the total for the slot's priority
1069 let slotEfforts = &self.slotUsedEffort[slot]! as auth(Mutate) &{Priority: UInt64}
1070 var newPriorityEffort = slotEfforts[txData.priority]! + txData.executionEffort
1071 slotEfforts[txData.priority] = newPriorityEffort
1072 var newTotalEffort: UInt64 = 0
1073 for priority in slotEfforts.keys {
1074 newTotalEffort = newTotalEffort.saturatingAdd(slotEfforts[priority]!)
1075 }
1076
1077 // Store the transaction in the transactions map
1078 self.transactions[txData.id] = txData
1079
1080 // Need to potentially reschedule low priority transactions to make room for the new transaction
1081 // Iterate through them and record which ones to reschedule until the total effort is less than the limit
1082 let lowTransactionsToReschedule: [UInt64] = []
1083 if newTotalEffort > self.config.slotTotalEffortLimit {
1084 let lowPriorityTransactions = transactionsForSlot[Priority.Low]!
1085 for id in lowPriorityTransactions.keys {
1086 if newTotalEffort <= self.config.slotTotalEffortLimit {
1087 break
1088 }
1089 lowTransactionsToReschedule.append(id)
1090 newTotalEffort = newTotalEffort.saturatingSubtract(lowPriorityTransactions[id]!)
1091 }
1092 }
1093
1094 // Reschedule low priority transactions if needed
1095 self.rescheduleLowPriorityTransactions(slot: slot, transactions: lowTransactionsToReschedule)
1096 }
1097
1098 /// rescheduleLowPriorityTransactions reschedules low priority transactions to make room for a new transaction
1099 /// @param slot: The slot that the transactions are currently scheduled at
1100 /// @param transactions: The transactions to reschedule
1101 access(self) fun rescheduleLowPriorityTransactions(slot: UFix64, transactions: [UInt64]) {
1102 for id in transactions {
1103 let tx = self.borrowTransaction(id: id)
1104 if tx == nil {
1105 emit CriticalIssue(message: "Invalid ID: \(id) transaction not found while rescheduling low priority transactions")
1106 continue
1107 }
1108
1109 if tx!.priority != Priority.Low {
1110 emit CriticalIssue(message: "Invalid Priority: Cannot reschedule transaction with id \(id) because it is not low priority")
1111 continue
1112 }
1113
1114 if tx!.scheduledTimestamp != slot {
1115 emit CriticalIssue(message: "Invalid Timestamp: Cannot reschedule transaction with id \(id) because it is not scheduled at the same slot as the new transaction")
1116 continue
1117 }
1118
1119 let newTimestamp = self.calculateScheduledTimestamp(
1120 timestamp: slot + 1.0,
1121 priority: Priority.Low,
1122 executionEffort: tx!.executionEffort
1123 )!
1124
1125 let effort = tx!.executionEffort
1126 let transactionData = self.removeTransaction(txData: tx!)
1127
1128 // Subtract the execution effort for this transaction from the slot's priority
1129 let slotEfforts = &self.slotUsedEffort[slot]! as auth(Mutate) &{Priority: UInt64}
1130 slotEfforts[Priority.Low] = slotEfforts[Priority.Low]!.saturatingSubtract(effort)
1131
1132 // Update the transaction's scheduled timestamp and add it back to the slot queue
1133 transactionData.setScheduledTimestamp(newTimestamp: newTimestamp)
1134 self.addTransaction(slot: newTimestamp, txData: transactionData)
1135 }
1136 }
1137
1138 /// remove the transaction from the slot queue.
1139 access(self) fun removeTransaction(txData: &TransactionData): TransactionData {
1140
1141 let transactionID = txData.id
1142 let slot = txData.scheduledTimestamp
1143 let transactionPriority = txData.priority
1144
1145 // remove transaction object
1146 let transactionObject = self.transactions.remove(key: transactionID)!
1147
1148 // garbage collect slots
1149 let transactionQueue = self.slotQueue[slot]!
1150
1151 if let priorityQueue = transactionQueue[transactionPriority] {
1152 priorityQueue[transactionID] = nil
1153 if priorityQueue.keys.length == 0 {
1154 transactionQueue.remove(key: transactionPriority)
1155 } else {
1156 transactionQueue[transactionPriority] = priorityQueue
1157 }
1158 }
1159 self.slotQueue[slot] = transactionQueue
1160
1161 // if the slot is now empty remove it from the maps
1162 if transactionQueue.keys.length == 0 {
1163 self.slotQueue.remove(key: slot)
1164 self.slotUsedEffort.remove(key: slot)
1165
1166 self.sortedTimestamps.remove(timestamp: slot)
1167 }
1168
1169 return transactionObject
1170 }
1171
1172 /// pendingQueue creates a list of transactions that are ready for execution.
1173 /// For transaction to be ready for execution it must be scheduled.
1174 ///
1175 /// The queue is sorted by timestamp and then by priority (high, medium, low).
1176 /// The queue will contain transactions from all timestamps that are in the past.
1177 /// Low priority transactions will only be added if there is effort available in the slot.
1178 /// The return value can be empty if there are no transactions ready for execution.
1179 access(Process) fun pendingQueue(): [&TransactionData] {
1180 let currentTimestamp = getCurrentBlock().timestamp
1181 var pendingTransactions: [&TransactionData] = []
1182
1183 // total effort across different timestamps guards collection being over the effort limit
1184 var collectionAvailableEffort = self.config.collectionEffortLimit
1185 var transactionsAvailableCount = self.config.collectionTransactionsLimit
1186
1187 // Collect past timestamps efficiently from sorted array
1188 let pastTimestamps = self.sortedTimestamps.getBefore(current: currentTimestamp)
1189
1190 for timestamp in pastTimestamps {
1191 let transactionPriorities = self.slotQueue[timestamp] ?? {}
1192 var high: [&TransactionData] = []
1193 var medium: [&TransactionData] = []
1194 var low: [&TransactionData] = []
1195
1196 for priority in transactionPriorities.keys {
1197 let transactionIDs = transactionPriorities[priority] ?? {}
1198 for id in transactionIDs.keys {
1199 let tx = self.borrowTransaction(id: id)
1200 if tx == nil {
1201 emit CriticalIssue(message: "Invalid ID: \(id) transaction not found while preparing pending queue")
1202 continue
1203 }
1204
1205 // Only add scheduled transactions to the queue
1206 if tx!.status != Status.Scheduled {
1207 continue
1208 }
1209
1210 // this is safeguard to prevent collection growing too large in case of block production slowdown
1211 if tx!.executionEffort >= collectionAvailableEffort || transactionsAvailableCount == 0 {
1212 emit CollectionLimitReached(
1213 collectionEffortLimit: transactionsAvailableCount == 0 ? nil : self.config.collectionEffortLimit,
1214 collectionTransactionsLimit: transactionsAvailableCount == 0 ? self.config.collectionTransactionsLimit : nil
1215 )
1216 break
1217 }
1218
1219 collectionAvailableEffort = collectionAvailableEffort.saturatingSubtract(tx!.executionEffort)
1220 transactionsAvailableCount = transactionsAvailableCount - 1
1221
1222 switch tx!.priority {
1223 case Priority.High:
1224 high.append(tx!)
1225 case Priority.Medium:
1226 medium.append(tx!)
1227 case Priority.Low:
1228 low.append(tx!)
1229 }
1230 }
1231 }
1232
1233 pendingTransactions = pendingTransactions
1234 .concat(high)
1235 .concat(medium)
1236 .concat(low)
1237 }
1238
1239 return pendingTransactions
1240 }
1241
1242 /// removeExecutedTransactions removes all transactions that are marked as executed.
1243 access(self) fun removeExecutedTransactions(_ currentTimestamp: UFix64) {
1244 let pastTimestamps = self.sortedTimestamps.getBefore(current: currentTimestamp)
1245 var numRemoved = 0
1246 let removalLimit = self.config.getTxRemovalLimit()
1247
1248 for timestamp in pastTimestamps {
1249 let transactionPriorities = self.slotQueue[timestamp] ?? {}
1250
1251 for priority in transactionPriorities.keys {
1252 let transactionIDs = transactionPriorities[priority] ?? {}
1253 for id in transactionIDs.keys {
1254
1255 numRemoved = numRemoved + 1
1256
1257 if UInt(numRemoved) >= removalLimit {
1258 emit RemovalLimitReached()
1259 return
1260 }
1261
1262 let tx = self.borrowTransaction(id: id)
1263 if tx == nil {
1264 emit CriticalIssue(message: "Invalid ID: \(id) transaction not found while removing executed transactions")
1265 continue
1266 }
1267
1268 // Only remove executed transactions
1269 if tx!.status != Status.Executed {
1270 continue
1271 }
1272
1273 // charge the full fee for transaction execution
1274 destroy tx!.payAndRefundFees(refundMultiplier: 0.0)
1275
1276 self.removeTransaction(txData: tx!)
1277 }
1278 }
1279 }
1280 }
1281
1282 /// process scheduled transactions and prepare them for execution.
1283 ///
1284 /// First, it removes transactions that have already been executed.
1285 /// Then, it iterates over past timestamps in the queue and processes the transactions that are
1286 /// eligible for execution. It also emits an event for each transaction that is processed.
1287 ///
1288 /// This function is only called by the FVM to process transactions.
1289 access(Process) fun process() {
1290 let currentTimestamp = getCurrentBlock().timestamp
1291 // Early exit if no timestamps need processing
1292 if !self.sortedTimestamps.hasBefore(current: currentTimestamp) {
1293 return
1294 }
1295
1296 self.removeExecutedTransactions(currentTimestamp)
1297
1298 let pendingTransactions = self.pendingQueue()
1299
1300 for tx in pendingTransactions {
1301
1302 emit PendingExecution(
1303 id: tx.id,
1304 priority: tx.priority.rawValue,
1305 executionEffort: tx.executionEffort,
1306 fees: tx.fees,
1307 transactionHandlerOwner: tx.handler.address,
1308 // Cannot use the real type identifier here because if
1309 // the handler contract is broken, it could cause the process function to fail
1310 transactionHandlerTypeIdentifier: ""
1311 )
1312
1313 // after pending execution event is emitted we set the transaction as executed because we
1314 // must rely on execution node to actually execute it. Execution of the transaction is
1315 // done in a separate transaction that calls executeTransaction(id) function.
1316 // Executing the transaction can not update the status of transaction or any other shared state,
1317 // since that blocks concurrent transaction execution.
1318 // Therefore an optimistic update to executed is made here to avoid race condition.
1319 tx.setStatus(newStatus: Status.Executed)
1320 }
1321 }
1322
1323 /// cancel a scheduled transaction and return a portion of the fees that were paid.
1324 ///
1325 /// @param id: The ID of the transaction to cancel
1326 /// @return: The fees to be returned to the caller
1327 access(Cancel) fun cancel(id: UInt64): @FlowToken.Vault {
1328 let tx = self.borrowTransaction(id: id) ??
1329 panic("Invalid ID: \(id) transaction not found")
1330
1331 assert(
1332 tx.status == Status.Scheduled,
1333 message: "Transaction must be in a scheduled state in order to be canceled"
1334 )
1335
1336 // Subtract the execution effort for this transaction from the slot's priority
1337 let slotEfforts = self.slotUsedEffort[tx.scheduledTimestamp]!
1338 slotEfforts[tx.priority] = slotEfforts[tx.priority]!.saturatingSubtract(tx.executionEffort)
1339 self.slotUsedEffort[tx.scheduledTimestamp] = slotEfforts
1340
1341 let totalFees = tx.fees
1342 let refundedFees <- tx.payAndRefundFees(refundMultiplier: self.config.refundMultiplier)
1343
1344 // if the transaction was canceled, add it to the canceled transactions array
1345 // maintain sorted order by inserting at the correct position
1346 var high = self.canceledTransactions.length
1347 var low = 0
1348 while low < high {
1349 let mid = (low+high)/2
1350 let midCanceledID = self.canceledTransactions[mid]
1351
1352 if midCanceledID == id {
1353 emit CriticalIssue(message: "Invalid ID: \(id) transaction already in canceled transactions array")
1354 } else if midCanceledID > id {
1355 high = mid
1356 } else {
1357 low = mid + 1
1358 }
1359 }
1360 self.canceledTransactions.insert(at: low, id)
1361
1362 // keep the array under the limit
1363 if UInt(self.canceledTransactions.length) > self.config.canceledTransactionsLimit {
1364 self.canceledTransactions.remove(at: 0)
1365 }
1366
1367 emit Canceled(
1368 id: tx.id,
1369 priority: tx.priority.rawValue,
1370 feesReturned: refundedFees.balance,
1371 feesDeducted: totalFees - refundedFees.balance,
1372 transactionHandlerOwner: tx.handler.address,
1373 transactionHandlerTypeIdentifier: tx.handlerTypeIdentifier
1374 )
1375
1376 self.removeTransaction(txData: tx)
1377
1378 return <-refundedFees
1379 }
1380
1381 /// execute transaction is a system function that is called by FVM to execute a transaction by ID.
1382 /// The transaction must be found and in correct state or the function panics and this is a fatal error
1383 ///
1384 /// This function is only called by the FVM to execute transactions.
1385 /// WARNING: this function should not change any shared state, it will be run concurrently and it must not be blocking.
1386 access(Execute) fun executeTransaction(id: UInt64) {
1387 let tx = self.borrowTransaction(id: id) ??
1388 panic("Invalid ID: Transaction with id \(id) not found")
1389
1390 assert (
1391 tx.status == Status.Executed,
1392 message: "Invalid ID: Cannot execute transaction with id \(id) because it has incorrect status \(tx.status.rawValue)"
1393 )
1394
1395 let transactionHandler = tx.handler.borrow()
1396 ?? panic("Invalid transaction handler: Could not borrow a reference to the transaction handler")
1397
1398 let handlerPublicPath = transactionHandler.resolveView(Type<PublicPath>()) as? PublicPath
1399
1400 emit Executed(
1401 id: tx.id,
1402 priority: tx.priority.rawValue,
1403 executionEffort: tx.executionEffort,
1404 transactionHandlerOwner: tx.handler.address,
1405 transactionHandlerTypeIdentifier: transactionHandler.getType().identifier,
1406 transactionHandlerUUID: transactionHandler.uuid,
1407 transactionHandlerPublicPath: handlerPublicPath
1408
1409 )
1410
1411 transactionHandler.executeTransaction(id: id, data: tx.getData())
1412 }
1413 }
1414
1415 /// Deposit fees to this contract's account's vault
1416 access(contract) fun depositFees(from: @FlowToken.Vault) {
1417 let vaultRef = self.account.storage.borrow<&FlowToken.Vault>(from: /storage/flowTokenVault)
1418 ?? panic("Unable to borrow reference to the default token vault")
1419 vaultRef.deposit(from: <-from)
1420 }
1421
1422 /// Withdraw fees from this contract's account's vault
1423 access(contract) fun withdrawFees(amount: UFix64): @FlowToken.Vault {
1424 let vaultRef = self.account.storage.borrow<auth(FungibleToken.Withdraw) &FlowToken.Vault>(from: /storage/flowTokenVault)
1425 ?? panic("Unable to borrow reference to the default token vault")
1426
1427 return <-vaultRef.withdraw(amount: amount) as! @FlowToken.Vault
1428 }
1429
1430 access(all) fun schedule(
1431 handlerCap: Capability<auth(Execute) &{TransactionHandler}>,
1432 data: AnyStruct?,
1433 timestamp: UFix64,
1434 priority: Priority,
1435 executionEffort: UInt64,
1436 fees: @FlowToken.Vault
1437 ): @ScheduledTransaction {
1438 return <-self.sharedScheduler.borrow()!.schedule(
1439 handlerCap: handlerCap,
1440 data: data,
1441 timestamp: timestamp,
1442 priority: priority,
1443 executionEffort: executionEffort,
1444 fees: <-fees
1445 )
1446 }
1447
1448 access(all) fun estimate(
1449 data: AnyStruct?,
1450 timestamp: UFix64,
1451 priority: Priority,
1452 executionEffort: UInt64
1453 ): EstimatedScheduledTransaction {
1454 return self.sharedScheduler.borrow()!
1455 .estimate(
1456 data: data,
1457 timestamp: timestamp,
1458 priority: priority,
1459 executionEffort: executionEffort,
1460 )
1461 }
1462
1463 /// Allows users to calculate the fee for a scheduled transaction without having to call the expensive estimate function
1464 /// @param executionEffort: The execution effort of the transaction
1465 /// @param priority: The priority of the transaction
1466 /// @param dataSizeMB: The size of the data to be stored with the scheduled transaction
1467 /// The user must calculate this data size themselves before calling this function
1468 /// But should be done in a separate script or transaction to avoid the expensive getSizeOfData function
1469 /// @return UFix64: The fee in Flow tokens that is required to pay for the transaction
1470 access(all) fun calculateFee(executionEffort: UInt64, priority: Priority, dataSizeMB: UFix64): UFix64 {
1471 return self.sharedScheduler.borrow()!.calculateFee(executionEffort: executionEffort, priority: priority, dataSizeMB: dataSizeMB)
1472 }
1473
1474 access(all) fun cancel(scheduledTx: @ScheduledTransaction): @FlowToken.Vault {
1475 let id = scheduledTx.id
1476 destroy scheduledTx
1477 return <-self.sharedScheduler.borrow()!.cancel(id: id)
1478 }
1479
1480 /// getTransactionData returns the transaction data for a given ID
1481 /// This function can only get the data for a transaction that is currently scheduled or pending execution
1482 /// because finalized transaction metadata is not stored in the contract
1483 /// @param id: The ID of the transaction to get the data for
1484 /// @return: The transaction data for the given ID
1485 access(all) view fun getTransactionData(id: UInt64): TransactionData? {
1486 return self.sharedScheduler.borrow()!.getTransaction(id: id)
1487 }
1488
1489 /// borrowHandlerForID returns an un-entitled reference to the transaction handler for a given ID
1490 /// The handler reference can be used to resolve views to get info about the handler and see where it is stored
1491 /// @param id: The ID of the transaction to get the handler for
1492 /// @return: An un-entitled reference to the transaction handler for the given ID
1493 access(all) view fun borrowHandlerForID(_ id: UInt64): &{TransactionHandler}? {
1494 return self.getTransactionData(id: id)?.borrowHandler()
1495 }
1496
1497 /// getCanceledTransactions returns the IDs of the transactions that have been canceled
1498 /// @return: The IDs of the transactions that have been canceled
1499 access(all) view fun getCanceledTransactions(): [UInt64] {
1500 return self.sharedScheduler.borrow()!.getCanceledTransactions()
1501 }
1502
1503
1504 access(all) view fun getStatus(id: UInt64): Status? {
1505 return self.sharedScheduler.borrow()!.getStatus(id: id)
1506 }
1507
1508 /// getTransactionsForTimeframe returns the IDs of the transactions that are scheduled for a given timeframe
1509 /// @param startTimestamp: The start timestamp to get the IDs for
1510 /// @param endTimestamp: The end timestamp to get the IDs for
1511 /// @return: The IDs of the transactions that are scheduled for the given timeframe
1512 access(all) fun getTransactionsForTimeframe(startTimestamp: UFix64, endTimestamp: UFix64): {UFix64: {UInt8: [UInt64]}} {
1513 return self.sharedScheduler.borrow()!.getTransactionsForTimeframe(startTimestamp: startTimestamp, endTimestamp: endTimestamp)
1514 }
1515
1516 access(all) view fun getSlotAvailableEffort(timestamp: UFix64, priority: Priority): UInt64 {
1517 // Remove fractional values from the timestamp
1518 let sanitizedTimestamp = UFix64(UInt64(timestamp))
1519 return self.sharedScheduler.borrow()!.getSlotAvailableEffort(sanitizedTimestamp: sanitizedTimestamp, priority: priority)
1520 }
1521
1522 access(all) fun getConfig(): {SchedulerConfig} {
1523 return self.sharedScheduler.borrow()!.getConfig()
1524 }
1525
1526 /// getSizeOfData takes a transaction's data
1527 /// argument and stores it in the contract account's storage,
1528 /// checking storage used before and after to see how large the data is in MB
1529 /// If data is nil, the function returns 0.0
1530 access(all) fun getSizeOfData(_ data: AnyStruct?): UFix64 {
1531 if data == nil {
1532 return 0.0
1533 } else {
1534 let type = data!.getType()
1535 if type.isSubtype(of: Type<Number>())
1536 || type.isSubtype(of: Type<Bool>())
1537 || type.isSubtype(of: Type<Address>())
1538 || type.isSubtype(of: Type<Character>())
1539 || type.isSubtype(of: Type<Capability>())
1540 {
1541 return 0.0
1542 }
1543 }
1544 let storagePath = /storage/dataTemp
1545 let storageUsedBefore = self.account.storage.used
1546 self.account.storage.save(data!, to: storagePath)
1547 let storageUsedAfter = self.account.storage.used
1548 self.account.storage.load<AnyStruct>(from: storagePath)
1549
1550 return FlowStorageFees.convertUInt64StorageBytesToUFix64Megabytes(storageUsedAfter.saturatingSubtract(storageUsedBefore))
1551 }
1552
1553 access(all) init() {
1554 self.storagePath = /storage/sharedScheduler
1555 let scheduler <- create SharedScheduler()
1556 let oldScheduler <- self.account.storage.load<@AnyResource>(from: self.storagePath)
1557 destroy oldScheduler
1558 self.account.storage.save(<-scheduler, to: self.storagePath)
1559
1560 self.sharedScheduler = self.account.capabilities.storage
1561 .issue<auth(Cancel) &SharedScheduler>(self.storagePath)
1562 }
1563}