Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
e1ffc9f
Fix supervisor unstuck vaults
holyfuchs Feb 25, 2026
05148e9
update submodule
holyfuchs Mar 3, 2026
53cad29
update submodule branch to v0
holyfuchs Mar 4, 2026
c21d5b4
address pr comments
holyfuchs Mar 9, 2026
3878bc4
update submodule
holyfuchs Mar 10, 2026
c7fa0e6
use linked list for stuck-scan
holyfuchs Mar 10, 2026
4133c85
Fix scheduler registry hygiene
liobrasil Mar 10, 2026
fcebdc7
Clean up scheduler follow-up issues
liobrasil Mar 10, 2026
32e6f2a
Avoid adding callback capability state
liobrasil Mar 10, 2026
ec71d67
Tighten healthy supervisor test isolation
liobrasil Mar 10, 2026
726a454
Align scheduler docs with current architecture
liobrasil Mar 10, 2026
a25e094
Merge pull request #204 from onflow/lionel/fix-scheduler-registry-hyg…
liobrasil Mar 10, 2026
0d23dd6
Merge branch 'main' into holyfuchs/supervisor-fix
holyfuchs Mar 12, 2026
20bdf4a
Merge branch 'main' into holyfuchs/supervisor-fix
holyfuchs Mar 16, 2026
98cb924
refactor: extract linked list into UInt64LinkedList contract
holyfuchs Mar 16, 2026
78414cd
Merge branch 'main' into holyfuchs/supervisor-fix
liobrasil Mar 17, 2026
1352ffe
Merge branch 'main' into holyfuchs/supervisor-fix
liobrasil Mar 17, 2026
327e00c
fix tests
holyfuchs Mar 18, 2026
720e689
fix pr comments
holyfuchs Mar 18, 2026
f50780a
fix failing tests
holyfuchs Mar 18, 2026
6b1a35c
Merge branch 'main' into holyfuchs/supervisor-fix
holyfuchs Mar 19, 2026
d48ceb5
use latest block when oracle price is needed
Kay-Zee Mar 19, 2026
ddbc6f7
remove uncommented code
holyfuchs Mar 19, 2026
509d7bc
Merge branch 'main' into holyfuchs/supervisor-fix
holyfuchs Mar 20, 2026
e34c545
use latest block on PMStrategies
holyfuchs Mar 20, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitmodules
Original file line number Diff line number Diff line change
Expand Up @@ -22,3 +22,4 @@
[submodule "lib/FlowALP"]
path = lib/FlowALP
url = git@github.com:onflow/FlowALP.git
branch = v0
45 changes: 38 additions & 7 deletions cadence/contracts/FlowYieldVaultsAutoBalancers.cdc
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,16 @@ access(all) contract FlowYieldVaultsAutoBalancers {
/// The path prefix used for StoragePath & PublicPath derivations
access(all) let pathPrefix: String

/// Storage path for the shared execution callback resource that reports to the registry (one per account)
access(self) let registryReportCallbackStoragePath: StoragePath

/// Callback resource invoked by each AutoBalancer after execution; calls Registry.reportExecution with its id
access(all) resource RegistryReportCallback: DeFiActions.AutoBalancerExecutionCallback {
access(all) fun onExecuted(balancerUUID: UInt64) {
FlowYieldVaultsSchedulerRegistry.reportExecution(yieldVaultID: balancerUUID)
}
}

/* --- PUBLIC METHODS --- */

/// Returns the path (StoragePath or PublicPath) at which an AutoBalancer is stored with the associated
Expand Down Expand Up @@ -82,7 +92,7 @@ access(all) contract FlowYieldVaultsAutoBalancers {
if autoBalancer == nil {
return false
}

let txnIDs = autoBalancer!.getScheduledTransactionIDs()
for txnID in txnIDs {
if autoBalancer!.borrowScheduledTransaction(id: txnID)?.status() == FlowTransactionScheduler.Status.Scheduled {
Expand All @@ -106,24 +116,24 @@ access(all) contract FlowYieldVaultsAutoBalancers {
if autoBalancer == nil {
return false
}

// Check if yield vault has recurring config (should be executing periodically)
let config = autoBalancer!.getRecurringConfig()
if config == nil {
return false // Not configured for recurring, can't be "stuck"
}

// Check if there's an active schedule
if self.hasActiveSchedule(id: id) {
return false // Has active schedule, not stuck
}

// Check if yield vault is overdue
let nextExpected = autoBalancer!.calculateNextExecutionTimestampAsConfigured()
if nextExpected == nil {
return true // Can't calculate next time, likely stuck
}

// If next expected time has passed and no active schedule, yield vault is stuck
return nextExpected! < getCurrentBlock().timestamp
}
Expand Down Expand Up @@ -163,6 +173,20 @@ access(all) contract FlowYieldVaultsAutoBalancers {
assert(!publishedCap,
message: "Published Capability collision found when publishing AutoBalancer for UniqueIdentifier.id \(uniqueID.id) at path \(publicPath)")

let registryReportCallbackCapabilityStoragePath =
StoragePath(identifier: "FlowYieldVaultsRegistryReportCallbackCapability")!
if self.account.storage.type(at: registryReportCallbackCapabilityStoragePath) == nil {
let sharedReportCap = self.account.capabilities.storage.issue<&{DeFiActions.AutoBalancerExecutionCallback}>(
self.registryReportCallbackStoragePath
)
self.account.storage.save(sharedReportCap, to: registryReportCallbackCapabilityStoragePath)
}
let reportCap = self.account.storage.copy<Capability<&{DeFiActions.AutoBalancerExecutionCallback}>>(
from: registryReportCallbackCapabilityStoragePath
) ?? panic(
"Missing shared registry report callback capability at \(registryReportCallbackCapabilityStoragePath)"
)

// create & save AutoBalancer with optional recurring config
let autoBalancer <- DeFiActions.createAutoBalancer(
oracle: oracle,
Expand All @@ -174,6 +198,7 @@ access(all) contract FlowYieldVaultsAutoBalancers {
recurringConfig: recurringConfig,
uniqueID: uniqueID
)
autoBalancer.setExecutionCallback(reportCap)
self.account.storage.save(<-autoBalancer, to: storagePath)
let autoBalancerRef = self._borrowAutoBalancer(uniqueID.id)

Expand Down Expand Up @@ -237,7 +262,7 @@ access(all) contract FlowYieldVaultsAutoBalancers {
let publicPath = self.deriveAutoBalancerPath(id: id, storage: false) as! PublicPath
// unpublish the public AutoBalancer Capability
let _ = self.account.capabilities.unpublish(publicPath)

// Collect controller IDs first (can't modify during iteration)
var controllersToDelete: [UInt64] = []
self.account.capabilities.storage.forEachController(forPath: storagePath, fun(_ controller: &StorageCapabilityController): Bool {
Expand All @@ -250,13 +275,19 @@ access(all) contract FlowYieldVaultsAutoBalancers {
controller.delete()
}
}

// load & burn the AutoBalancer (this also handles any pending scheduled transactions via burnCallback)
let autoBalancer <-self.account.storage.load<@DeFiActions.AutoBalancer>(from: storagePath)
Burner.burn(<-autoBalancer)
}

init() {
self.pathPrefix = "FlowYieldVaultsAutoBalancer_"
self.registryReportCallbackStoragePath = StoragePath(identifier: "FlowYieldVaultsRegistryReportCallback")!

// Ensure shared execution callback exists (reports this account's executions to Registry)
if self.account.storage.type(at: self.registryReportCallbackStoragePath) == nil {
self.account.storage.save(<-create RegistryReportCallback(), to: self.registryReportCallbackStoragePath)
}
}
}
73 changes: 58 additions & 15 deletions cadence/contracts/FlowYieldVaultsSchedulerRegistry.cdc
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import "FlowTransactionScheduler"
import "DeFiActions"
import "UInt64LinkedList"


/// FlowYieldVaultsSchedulerRegistry
Expand Down Expand Up @@ -41,6 +42,10 @@ access(all) contract FlowYieldVaultsSchedulerRegistry {
/// Maximum number of yield vaults to process in a single Supervisor batch
access(all) let MAX_BATCH_SIZE: Int

/* --- STORAGE PATHS --- */

access(all) let executionListStoragePath: StoragePath

/* --- STATE --- */

/// Registry of all yield vault IDs that participate in scheduling
Expand All @@ -58,6 +63,15 @@ access(all) contract FlowYieldVaultsSchedulerRegistry {
/// Stored as a dictionary for O(1) add/remove; iteration gives the pending set
access(self) var pendingQueue: {UInt64: Bool}

/* --- PRIVATE LIST ACCESSOR --- */

/// Borrow the execution-order linked list from account storage.
access(self) fun _list(): &UInt64LinkedList.List {
return self.account.storage
.borrow<&UInt64LinkedList.List>(from: self.executionListStoragePath)
?? panic("UInt64LinkedList.List resource missing from storage")
}

/* --- ACCOUNT-LEVEL FUNCTIONS --- */

/// Register a YieldVault and store its handler and schedule capabilities (idempotent)
Expand All @@ -73,9 +87,28 @@ access(all) contract FlowYieldVaultsSchedulerRegistry {
self.yieldVaultRegistry[yieldVaultID] = true
self.handlerCaps[yieldVaultID] = handlerCap
self.scheduleCaps[yieldVaultID] = scheduleCap
// New vaults go to the head; they haven't executed yet but are freshly registered.
// If already in the list (idempotent re-register), remove first to avoid duplicates.
let list = self._list()
if list.contains(id: yieldVaultID) {
let _ = list.remove(id: yieldVaultID)
}
list.insertAtHead(id: yieldVaultID)
emit YieldVaultRegistered(yieldVaultID: yieldVaultID)
}

/// Called on every execution. Moves yieldVaultID to the head (most recently executed)
/// so the Supervisor scans from the tail (least recently executed) for stuck detection — O(1).
/// If the list entry is unexpectedly missing, reinsert it to restore the ordering structure.
access(account) fun reportExecution(yieldVaultID: UInt64) {
if !(self.yieldVaultRegistry[yieldVaultID] ?? false) {
return
}
let list = self._list()
let _ = list.remove(id: yieldVaultID)
list.insertAtHead(id: yieldVaultID)
}

/// Adds a yield vault to the pending queue for seeding by the Supervisor
access(account) fun enqueuePending(yieldVaultID: UInt64) {
if self.yieldVaultRegistry[yieldVaultID] == true {
Expand All @@ -92,12 +125,13 @@ access(all) contract FlowYieldVaultsSchedulerRegistry {
}
}

/// Unregister a YieldVault (idempotent) - removes from registry, capabilities, and pending queue
/// Unregister a YieldVault (idempotent) - removes from registry, capabilities, pending queue, and linked list
access(account) fun unregister(yieldVaultID: UInt64) {
self.yieldVaultRegistry.remove(key: yieldVaultID)
self.handlerCaps.remove(key: yieldVaultID)
self.scheduleCaps.remove(key: yieldVaultID)
let _r = self.yieldVaultRegistry.remove(key: yieldVaultID)
let _h = self.handlerCaps.remove(key: yieldVaultID)
let _s = self.scheduleCaps.remove(key: yieldVaultID)
let pending = self.pendingQueue.remove(key: yieldVaultID)
let _ = self._list().remove(id: yieldVaultID)
emit YieldVaultUnregistered(yieldVaultID: yieldVaultID, wasInPendingQueue: pending != nil)
}

Expand All @@ -109,7 +143,7 @@ access(all) contract FlowYieldVaultsSchedulerRegistry {
.load<Capability<auth(FlowTransactionScheduler.Execute) &{FlowTransactionScheduler.TransactionHandler}>>(
from: storedCapPath
)
self.account.storage.save(cap,to: storedCapPath)
self.account.storage.save(cap, to: storedCapPath)
}

/* --- VIEW FUNCTIONS --- */
Expand Down Expand Up @@ -155,20 +189,20 @@ access(all) contract FlowYieldVaultsSchedulerRegistry {

/// Get paginated pending yield vault IDs
/// @param page: The page number (0-indexed)
/// @param size: The page size (defaults to MAX_BATCH_SIZE if nil)
access(all) view fun getPendingYieldVaultIDsPaginated(page: Int, size: Int?): [UInt64] {
let pageSize = size ?? self.MAX_BATCH_SIZE
/// @param size: The page size (defaults to MAX_BATCH_SIZE if 0)
access(all) view fun getPendingYieldVaultIDsPaginated(page: Int, size: UInt): [UInt64] {
let pageSize = size == 0 ? self.MAX_BATCH_SIZE : Int(size)
let allPending = self.pendingQueue.keys
let startIndex = page * pageSize

if startIndex >= allPending.length {
return []
}
let endIndex = startIndex + pageSize > allPending.length
? allPending.length

let endIndex = startIndex + pageSize > allPending.length
? allPending.length
: startIndex + pageSize

return allPending.slice(from: startIndex, upTo: endIndex)
}

Expand All @@ -177,6 +211,15 @@ access(all) contract FlowYieldVaultsSchedulerRegistry {
return self.pendingQueue.length
}

/// Returns up to `limit` vault IDs starting from the tail (least recently executed).
/// Supervisor should only scan these for stuck detection instead of all registered vaults.
/// @param limit: Maximum number of IDs to return (caller typically passes MAX_BATCH_SIZE)
access(all) fun getStuckScanCandidates(limit: UInt): [UInt64] {
return self.account.storage
.borrow<&UInt64LinkedList.List>(from: self.executionListStoragePath)!
.tailWalk(limit: limit)
}

/// Get global Supervisor capability, if set
/// NOTE: Access restricted - only used internally by the scheduler
access(account)
Expand All @@ -189,11 +232,11 @@ access(all) contract FlowYieldVaultsSchedulerRegistry {

init() {
self.MAX_BATCH_SIZE = 5 // Process up to 5 yield vaults per Supervisor run
self.executionListStoragePath = /storage/FlowYieldVaultsExecutionList
self.yieldVaultRegistry = {}
self.handlerCaps = {}
self.scheduleCaps = {}
self.pendingQueue = {}
self.account.storage.save(<- UInt64LinkedList.createList(), to: self.executionListStoragePath)
}
}


28 changes: 6 additions & 22 deletions cadence/contracts/FlowYieldVaultsSchedulerV1.cdc
Original file line number Diff line number Diff line change
Expand Up @@ -172,7 +172,7 @@ access(all) contract FlowYieldVaultsSchedulerV1 {
/// "priority": UInt8 (0=High,1=Medium,2=Low) - for Supervisor self-rescheduling
/// "executionEffort": UInt64 - for Supervisor self-rescheduling
/// "recurringInterval": UFix64 (for Supervisor self-rescheduling)
/// "scanForStuck": Bool (default true - scan all registered yield vaults for stuck ones)
/// "scanForStuck": Bool (default true - scan up to MAX_BATCH_SIZE least-recently-executed vaults for stuck ones)
/// }
access(FlowTransactionScheduler.Execute) fun executeTransaction(id: UInt64, data: AnyStruct?) {
let cfg = data as? {String: AnyStruct} ?? {}
Expand All @@ -186,24 +186,8 @@ access(all) contract FlowYieldVaultsSchedulerV1 {

// STEP 1: State-based detection - scan for stuck yield vaults
if scanForStuck {
// TODO: add pagination - this will inevitably fails and at minimum creates inconsistent execution
// effort between runs
let registeredYieldVaults = FlowYieldVaultsSchedulerRegistry.getRegisteredYieldVaultIDs()
var scanned = 0
for yieldVaultID in registeredYieldVaults {
if scanned >= FlowYieldVaultsSchedulerRegistry.MAX_BATCH_SIZE {
break
}
scanned = scanned + 1

// Skip if already in pending queue
// TODO: This is extremely inefficient - accessing from mapping is preferrable to iterating over
// an array
if FlowYieldVaultsSchedulerRegistry.getPendingYieldVaultIDs().contains(yieldVaultID) {
continue
}

// Check if yield vault is stuck (has recurring config, no active schedule, overdue)
let candidates = FlowYieldVaultsSchedulerRegistry.getStuckScanCandidates(limit: UInt(FlowYieldVaultsSchedulerRegistry.MAX_BATCH_SIZE))
for yieldVaultID in candidates {
if FlowYieldVaultsAutoBalancers.isStuckYieldVault(id: yieldVaultID) {
FlowYieldVaultsSchedulerRegistry.enqueuePending(yieldVaultID: yieldVaultID)
emit StuckYieldVaultDetected(yieldVaultID: yieldVaultID)
Expand All @@ -212,8 +196,8 @@ access(all) contract FlowYieldVaultsSchedulerV1 {
}

// STEP 2: Process pending yield vaults - recover them via Schedule capability
let pendingYieldVaults = FlowYieldVaultsSchedulerRegistry.getPendingYieldVaultIDsPaginated(page: 0, size: nil)
let pendingYieldVaults = FlowYieldVaultsSchedulerRegistry.getPendingYieldVaultIDsPaginated(page: 0, size: UInt(FlowYieldVaultsSchedulerRegistry.MAX_BATCH_SIZE))

for yieldVaultID in pendingYieldVaults {
// Get Schedule capability for this yield vault
let scheduleCap = FlowYieldVaultsSchedulerRegistry.getScheduleCap(yieldVaultID: yieldVaultID)
Expand Down Expand Up @@ -457,7 +441,7 @@ access(all) contract FlowYieldVaultsSchedulerV1 {

// Initialize paths
self.SupervisorStoragePath = /storage/FlowYieldVaultsSupervisor

// Configure Supervisor at deploy time
self.ensureSupervisorConfigured()
}
Expand Down
4 changes: 2 additions & 2 deletions cadence/contracts/FlowYieldVaultsStrategiesV2.cdc
Original file line number Diff line number Diff line change
Expand Up @@ -857,7 +857,7 @@ access(all) contract FlowYieldVaultsStrategiesV2 {
}

access(all) view fun getSupportedComposers(): {Type: Bool} {
return {
return {
Type<@MorphoERC4626StrategyComposer>(): true
}
}
Expand Down Expand Up @@ -994,7 +994,7 @@ access(all) contract FlowYieldVaultsStrategiesV2 {
fun _createRecurringConfig(withID: DeFiActions.UniqueIdentifier?): DeFiActions.AutoBalancerRecurringConfig {
// Create txnFunder that can provide/accept FLOW for scheduling fees
let txnFunder = self._createTxnFunder(withID: withID)

return DeFiActions.AutoBalancerRecurringConfig(
interval: 60 * 10, // Rebalance every 10 minutes
priority: FlowTransactionScheduler.Priority.Medium,
Expand Down
Loading
Loading