Smart Contract
FlowYieldVaultsSchedulerV1
A.b1d63873c3cc9f79.FlowYieldVaultsSchedulerV1
1// standards
2import FungibleToken from 0xf233dcee88fe0abe
3import FlowToken from 0x1654653399040a61
4// Flow system contracts
5import FlowTransactionScheduler from 0xe467b9dd11fa00df
6// DeFiActions
7import DeFiActions from 0x6d888f175c158410
8// Registry storage (separate contract)
9import FlowYieldVaultsSchedulerRegistry from 0xb1d63873c3cc9f79
10// AutoBalancer management (for detecting stuck yield vaults)
11import FlowYieldVaultsAutoBalancers from 0xb1d63873c3cc9f79
12
13/// FlowYieldVaultsScheduler
14///
15/// This contract provides the Supervisor for recovery of stuck AutoBalancers.
16///
17/// Architecture:
18/// - AutoBalancers are configured with recurringConfig at creation in FlowYieldVaultsStrategies
19/// - AutoBalancers self-schedule subsequent executions via their native mechanism
20/// - FlowYieldVaultsAutoBalancers handles registration with the registry and starts scheduling
21/// - The Supervisor is a recovery mechanism for AutoBalancers that fail to self-schedule
22///
23/// Key Features:
24/// - Supervisor detects stuck yield vaults (failed to self-schedule) and recovers them
25/// - Uses Schedule capability to directly call AutoBalancer.scheduleNextRebalance()
26/// - Query and estimation functions for scripts
27///
28access(all) contract FlowYieldVaultsSchedulerV1 {
29
30 /* --- FIELDS --- */
31
32 /// Default recurring interval in seconds (used when not specified)
33 access(all) var DEFAULT_RECURRING_INTERVAL: UFix64
34
35 /// Default priority for recurring schedules
36 access(all) var DEFAULT_PRIORITY: UInt8 // 1 = Medium
37
38 /// Default execution effort for scheduled transactions
39 access(all) var DEFAULT_EXECUTION_EFFORT: UInt64
40
41 /// Minimum fee fallback when estimation returns nil
42 access(all) var MIN_FEE_FALLBACK: UFix64
43
44 /// Fee margin multiplier to add buffer to estimated fees (1.2 = 20% buffer)
45 access(all) var FEE_MARGIN_MULTIPLIER: UFix64
46
47 /* --- PATHS --- */
48
49 /// Storage path for the Supervisor resource
50 access(all) let SupervisorStoragePath: StoragePath
51
52 /* --- EVENTS --- */
53
54 /// Emitted when the Supervisor successfully recovers a stuck yield vault
55 access(all) event YieldVaultRecovered(
56 yieldVaultID: UInt64
57 )
58
59 /// Emitted when Supervisor fails to recover a yield vault
60 access(all) event YieldVaultRecoveryFailed(
61 yieldVaultID: UInt64,
62 error: String
63 )
64
65 /// Emitted when Supervisor detects a stuck yield vault via state-based scanning
66 access(all) event StuckYieldVaultDetected(
67 yieldVaultID: UInt64
68 )
69
70 /// Emitted when Supervisor self-reschedules
71 access(all) event SupervisorRescheduled(
72 scheduledTransactionID: UInt64,
73 timestamp: UFix64
74 )
75
76 /// Emitted when Supervisor fails to self-reschedule.
77 ///
78 /// This is primarily used to surface insufficient fee vault balance, which would otherwise
79 /// cause the Supervisor to stop monitoring without any on-chain signal.
80 access(all) event SupervisorRescheduleFailed(
81 timestamp: UFix64,
82 requiredFee: UFix64?,
83 availableBalance: UFix64?,
84 error: String
85 )
86
87 /// Entitlement to schedule transactions
88 access(all) entitlement Schedule
89
90 /* --- RESOURCES --- */
91
92 access(all) entitlement Configure
93
94 /// Supervisor - The recovery mechanism for stuck AutoBalancers
95 ///
96 /// The Supervisor:
97 /// - Detects stuck yield vaults (AutoBalancers that failed to self-schedule)
98 /// - Recovers stuck yield vaults by directly calling scheduleNextRebalance() via Schedule capability
99 /// - Can self-reschedule for perpetual operation
100 ///
101 /// Primary scheduling is done by AutoBalancers themselves via their native recurringConfig.
102 /// The Supervisor is only for recovery when that fails.
103 ///
104 access(all) resource Supervisor: FlowTransactionScheduler.TransactionHandler {
105 /// Capability to withdraw FLOW for Supervisor's own scheduling fees
106 access(self) let feesCap: Capability<auth(FungibleToken.Withdraw) &FlowToken.Vault>
107 /// Internally managed scheduled transaction for Supervisor self-rescheduling
108 access(self) var _scheduledTransaction: @FlowTransactionScheduler.ScheduledTransaction?
109
110 init(
111 feesCap: Capability<auth(FungibleToken.Withdraw) &FlowToken.Vault>
112 ) {
113 self.feesCap = feesCap
114 self._scheduledTransaction <- nil
115 }
116
117 /// Returns the ID of the internally managed scheduled transaction, or nil if not scheduled
118 ///
119 /// @return UInt64?: The ID of the internally managed scheduled transaction, or nil if not scheduled
120 access(all) view fun getScheduledTransactionID(): UInt64? {
121 return self._scheduledTransaction?.id
122 }
123
124 /* --- CONFIGURE FUNCTIONS --- */
125
126 /// Sets the default recurring interval for Supervisor self-rescheduling
127 /// @param interval: The interval to set
128 access(Configure) fun setDefaultRecurringInterval(_ interval: UFix64) {
129 FlowYieldVaultsSchedulerV1.DEFAULT_RECURRING_INTERVAL = interval
130 }
131
132 /// Sets the default execution effort for Supervisor self-rescheduling
133 /// @param effort: The execution effort to set
134 access(Configure) fun setDefaultExecutionEffort(_ effort: UInt64) {
135 FlowYieldVaultsSchedulerV1.DEFAULT_EXECUTION_EFFORT = effort
136 }
137
138 /// Sets the default minimum fee fallback for Supervisor self-rescheduling
139 /// @param fallback: The minimum fee fallback to set
140 access(Configure) fun setDefaultMinFeeFallback(_ fallback: UFix64) {
141 FlowYieldVaultsSchedulerV1.MIN_FEE_FALLBACK = fallback
142 }
143
144 /// Sets the default fee margin multiplier for Supervisor self-rescheduling
145 /// TODO: Determine if this field is even necessary
146 /// @param marginMultiplier: The margin multiplier to set
147 access(Configure) fun setDefaultFeeMarginMultiplier(_ marginMultiplier: UFix64) {
148 FlowYieldVaultsSchedulerV1.FEE_MARGIN_MULTIPLIER = marginMultiplier
149 }
150
151 /// Sets the default priority for Supervisor self-rescheduling
152 ///
153 /// @param priority: The priority to set
154 access(Configure) fun setDefaultPriority(_ priority: FlowTransactionScheduler.Priority) {
155 FlowYieldVaultsSchedulerV1.DEFAULT_PRIORITY = priority.rawValue
156 }
157
158 /* --- TRANSACTION HANDLER --- */
159
160 /// Detects and recovers stuck yield vaults by directly calling their scheduleNextRebalance().
161 ///
162 /// Detection methods:
163 /// 1. State-based: Scans for registered yield vaults with no active schedule that are overdue
164 ///
165 /// Recovery method:
166 /// - Uses Schedule capability to call AutoBalancer.scheduleNextRebalance() directly
167 /// - The AutoBalancer schedules itself using its own fee source
168 /// - This is simpler than the previous approach of Supervisor scheduling on behalf of AutoBalancer
169 ///
170 /// data accepts optional config:
171 /// {
172 /// "priority": UInt8 (0=High,1=Medium,2=Low) - for Supervisor self-rescheduling
173 /// "executionEffort": UInt64 - for Supervisor self-rescheduling
174 /// "recurringInterval": UFix64 (for Supervisor self-rescheduling)
175 /// "scanForStuck": Bool (default true - scan all registered yield vaults for stuck ones)
176 /// }
177 access(FlowTransactionScheduler.Execute) fun executeTransaction(id: UInt64, data: AnyStruct?) {
178 let cfg = data as? {String: AnyStruct} ?? {}
179 let priorityRaw = cfg["priority"] as? UInt8 ?? FlowYieldVaultsSchedulerV1.DEFAULT_PRIORITY
180 let executionEffort = cfg["executionEffort"] as? UInt64 ?? FlowYieldVaultsSchedulerV1.DEFAULT_EXECUTION_EFFORT
181 let recurringInterval = cfg["recurringInterval"] as? UFix64
182 let scanForStuck = cfg["scanForStuck"] as? Bool ?? true
183
184 let priority = FlowTransactionScheduler.Priority(rawValue: priorityRaw)
185 ?? FlowTransactionScheduler.Priority.Medium
186
187 // STEP 1: State-based detection - scan for stuck yield vaults
188 if scanForStuck {
189 // TODO: add pagination - this will inevitably fails and at minimum creates inconsistent execution
190 // effort between runs
191 let registeredYieldVaults = FlowYieldVaultsSchedulerRegistry.getRegisteredYieldVaultIDs()
192 var scanned = 0
193 for yieldVaultID in registeredYieldVaults {
194 if scanned >= FlowYieldVaultsSchedulerRegistry.MAX_BATCH_SIZE {
195 break
196 }
197 scanned = scanned + 1
198
199 // Skip if already in pending queue
200 // TODO: This is extremely inefficient - accessing from mapping is preferrable to iterating over
201 // an array
202 if FlowYieldVaultsSchedulerRegistry.getPendingYieldVaultIDs().contains(yieldVaultID) {
203 continue
204 }
205
206 // Check if yield vault is stuck (has recurring config, no active schedule, overdue)
207 if FlowYieldVaultsAutoBalancers.isStuckYieldVault(id: yieldVaultID) {
208 FlowYieldVaultsSchedulerRegistry.enqueuePending(yieldVaultID: yieldVaultID)
209 emit StuckYieldVaultDetected(yieldVaultID: yieldVaultID)
210 }
211 }
212 }
213
214 // STEP 2: Process pending yield vaults - recover them via Schedule capability
215 let pendingYieldVaults = FlowYieldVaultsSchedulerRegistry.getPendingYieldVaultIDsPaginated(page: 0, size: nil)
216
217 for yieldVaultID in pendingYieldVaults {
218 // Get Schedule capability for this yield vault
219 let scheduleCap = FlowYieldVaultsSchedulerRegistry.getScheduleCap(yieldVaultID: yieldVaultID)
220 if scheduleCap == nil || !scheduleCap!.check() {
221 emit YieldVaultRecoveryFailed(yieldVaultID: yieldVaultID, error: "Invalid Schedule capability")
222 continue
223 }
224
225 // Borrow the AutoBalancer and call scheduleNextRebalance() directly
226 let autoBalancerRef = scheduleCap!.borrow()!
227
228 if let scheduleError = autoBalancerRef.scheduleNextRebalance(whileExecuting: nil) {
229 emit YieldVaultRecoveryFailed(yieldVaultID: yieldVaultID, error: scheduleError)
230 // Leave in pending queue for retry on next Supervisor run
231 continue
232 }
233
234 // Successfully recovered - dequeue from pending
235 FlowYieldVaultsSchedulerRegistry.dequeuePending(yieldVaultID: yieldVaultID)
236 emit YieldVaultRecovered(yieldVaultID: yieldVaultID)
237 }
238
239 // STEP 3: Self-reschedule for perpetual operation if configured
240 if let interval = recurringInterval {
241 self.scheduleNextRecurringExecution(
242 recurringInterval: interval,
243 priority: priority,
244 executionEffort: executionEffort,
245 scanForStuck: scanForStuck
246 )
247 }
248 }
249
250 /// Self-reschedules the Supervisor for perpetual operation.
251 ///
252 /// This function handles the scheduling of the next Supervisor execution,
253 /// including fee estimation, withdrawal, and transaction scheduling.
254 ///
255 /// @param recurringInterval: The interval in seconds until the next execution
256 /// @param priority: The priority level for the scheduled transaction
257 /// @param executionEffort: The execution effort estimate for the transaction
258 /// @param scanForStuck: Whether to scan for stuck yield vaults in the next execution
259 access(Schedule) fun scheduleNextRecurringExecution(
260 recurringInterval: UFix64,
261 priority: FlowTransactionScheduler.Priority,
262 executionEffort: UInt64,
263 scanForStuck: Bool
264 ) {
265 let ref = &self._scheduledTransaction as &FlowTransactionScheduler.ScheduledTransaction?
266
267 if ref?.status() == FlowTransactionScheduler.Status.Scheduled {
268 // already scheduled - do nothing
269 return
270 }
271 let txn <- self._scheduledTransaction <- nil
272 destroy txn
273
274 let nextTimestamp = getCurrentBlock().timestamp + recurringInterval
275
276 let supervisorCap = FlowYieldVaultsSchedulerRegistry.getSupervisorCap()
277 if supervisorCap == nil {
278 emit SupervisorRescheduleFailed(
279 timestamp: nextTimestamp,
280 requiredFee: nil,
281 availableBalance: nil,
282 error: "Missing Supervisor capability"
283 )
284 return
285 }
286 if !supervisorCap!.check() {
287 emit SupervisorRescheduleFailed(
288 timestamp: nextTimestamp,
289 requiredFee: nil,
290 availableBalance: nil,
291 error: "Invalid Supervisor capability"
292 )
293 return
294 }
295
296 let est = FlowYieldVaultsSchedulerV1.estimateSchedulingCost(
297 timestamp: nextTimestamp,
298 priority: priority,
299 executionEffort: executionEffort
300 )
301 let baseFee = est.flowFee ?? FlowYieldVaultsSchedulerV1.MIN_FEE_FALLBACK
302 let required = baseFee * FlowYieldVaultsSchedulerV1.FEE_MARGIN_MULTIPLIER
303
304 let vaultRef = self.feesCap.borrow()
305 if vaultRef == nil {
306 emit SupervisorRescheduleFailed(
307 timestamp: nextTimestamp,
308 requiredFee: required,
309 availableBalance: nil,
310 error: "Could not borrow fee vault"
311 )
312 return
313 }
314 if vaultRef!.balance < required {
315 emit SupervisorRescheduleFailed(
316 timestamp: nextTimestamp,
317 requiredFee: required,
318 availableBalance: vaultRef!.balance,
319 error: "Insufficient fee vault balance"
320 )
321 return
322 }
323
324 let fees <- vaultRef!.withdraw(amount: required) as! @FlowToken.Vault
325
326 let nextData = {
327 "priority": priority.rawValue,
328 "executionEffort": executionEffort,
329 "recurringInterval": recurringInterval,
330 "scanForStuck": scanForStuck
331 }
332
333 let selfTxn <- FlowTransactionScheduler.schedule(
334 handlerCap: supervisorCap!,
335 data: nextData,
336 timestamp: nextTimestamp,
337 priority: priority,
338 executionEffort: executionEffort,
339 fees: <-fees
340 )
341
342 emit SupervisorRescheduled(
343 scheduledTransactionID: selfTxn.id,
344 timestamp: nextTimestamp
345 )
346
347 self._scheduledTransaction <-! selfTxn
348 }
349
350 /// Cancels the scheduled transaction if it is scheduled.
351 ///
352 /// @param refundReceiver: The receiver of the refunded vault, or nil to deposit to the internal feesCap
353 ///
354 /// @return @FlowToken.Vault?: The refunded vault, or nil if a scheduled transaction is not found
355 access(Schedule) fun cancelScheduledTransaction(refundReceiver: &{FungibleToken.Vault}?): @FlowToken.Vault? {
356 // nothing to cancel - nil or not scheduled
357 if self._scheduledTransaction == nil
358 || self._scheduledTransaction?.status() != FlowTransactionScheduler.Status.Scheduled {
359 return nil
360 }
361 // cancel the scheduled transaction & deposit refund to receiver if provided
362 let txnID = self.getScheduledTransactionID()!
363 let txn <- self._scheduledTransaction <- nil
364 let refund <- FlowTransactionScheduler.cancel(scheduledTx: <-txn!)
365 if let receiver = refundReceiver {
366 receiver.deposit(from: <-refund)
367 } else {
368 let feeReceiver = self.feesCap.borrow()
369 ?? panic("Could not borrow fees receiver to deposit refund of \(refund.balance) FLOW when cancelling scheduled transaction id \(txnID)")
370 feeReceiver.deposit(from: <-refund)
371 }
372 return nil
373 }
374 }
375
376 /* --- PRIVATE FUNCTIONS --- */
377
378 /// Creates a Supervisor handler.
379 access(self) fun createSupervisor(): @Supervisor {
380 let feesCap = self.account.capabilities.storage
381 .issue<auth(FungibleToken.Withdraw) &FlowToken.Vault>(/storage/flowTokenVault)
382 return <- create Supervisor(feesCap: feesCap)
383 }
384
385 /* --- PUBLIC FUNCTIONS --- */
386
387 /// Estimates the cost of scheduling a transaction at a given timestamp
388 access(all) fun estimateSchedulingCost(
389 timestamp: UFix64,
390 priority: FlowTransactionScheduler.Priority,
391 executionEffort: UInt64
392 ): FlowTransactionScheduler.EstimatedScheduledTransaction {
393 let maximumSizeData: {String: AnyStruct} = {
394 "priority": priority.rawValue,
395 "executionEffort": executionEffort,
396 "recurringInterval": UFix64.max,
397 "scanForStuck": true
398 }
399 return FlowTransactionScheduler.estimate(
400 data: maximumSizeData,
401 timestamp: timestamp,
402 priority: priority,
403 executionEffort: executionEffort
404 )
405 }
406
407 /// Ensures the Supervisor is configured and registered.
408 /// Creates Supervisor if not exists, issues capability, and registers with Registry.
409 /// Note: This is access(all) because the Supervisor is owned by the contract account
410 /// and uses contract account funds. The function is idempotent and safe to call multiple times.
411 access(all) fun ensureSupervisorConfigured() {
412 // Create and save Supervisor if not exists
413 if self.account.storage.type(at: self.SupervisorStoragePath) == nil {
414 let supervisor <- self.createSupervisor()
415 self.account.storage.save(<-supervisor, to: self.SupervisorStoragePath)
416 }
417
418 // Check if Supervisor capability is already registered
419 if FlowYieldVaultsSchedulerRegistry.getSupervisorCap() != nil {
420 return
421 }
422
423 // Issue capability and register
424 let cap = self.account.capabilities.storage.issue<auth(FlowTransactionScheduler.Execute) &{FlowTransactionScheduler.TransactionHandler}>(
425 self.SupervisorStoragePath
426 )
427 FlowYieldVaultsSchedulerRegistry.setSupervisorCap(cap: cap)
428 }
429
430 /* --- ACCOUNT FUNCTIONS --- */
431
432 /// Borrows the Supervisor reference (account-restricted for internal use)
433 access(account) fun borrowSupervisor(): &Supervisor? {
434 return self.account.storage.borrow<&Supervisor>(from: self.SupervisorStoragePath)
435 }
436
437 /// Manually enqueues a registered yield vault to the pending queue for recovery.
438 /// This allows manual triggering of recovery for a specific yield vault.
439 ///
440 /// @param yieldVaultID: The ID of the registered yield vault to enqueue
441 ///
442 access(account) fun enqueuePendingYieldVault(yieldVaultID: UInt64) {
443 assert(
444 FlowYieldVaultsSchedulerRegistry.isRegistered(yieldVaultID: yieldVaultID),
445 message: "enqueuePendingYieldVault: YieldVault #\(yieldVaultID.toString()) is not registered"
446 )
447 FlowYieldVaultsSchedulerRegistry.enqueuePending(yieldVaultID: yieldVaultID)
448 }
449
450 init() {
451 // Initialize constants
452 self.DEFAULT_RECURRING_INTERVAL = 60.0 * 10.0 // 10 minutes
453 self.DEFAULT_PRIORITY = 1 // Medium
454 self.DEFAULT_EXECUTION_EFFORT = 800
455 self.MIN_FEE_FALLBACK = 0.00005
456 self.FEE_MARGIN_MULTIPLIER = 1.0
457
458 // Initialize paths
459 self.SupervisorStoragePath = /storage/FlowYieldVaultsSupervisor
460
461 // Configure Supervisor at deploy time
462 self.ensureSupervisorConfigured()
463 }
464}
465