From 89c9ba0e5375eec7a77219481fed183c69272e66 Mon Sep 17 00:00:00 2001 From: liobrasil Date: Fri, 3 Apr 2026 06:55:08 -0400 Subject: [PATCH 1/5] add batch auto-balancer relaunch admin tx --- ...auto_balancers_and_schedule_supervisor.cdc | 188 ++++++++++++++++++ 1 file changed, 188 insertions(+) create mode 100644 cadence/transactions/flow-yield-vaults/admin/batch_relaunch_auto_balancers_and_schedule_supervisor.cdc diff --git a/cadence/transactions/flow-yield-vaults/admin/batch_relaunch_auto_balancers_and_schedule_supervisor.cdc b/cadence/transactions/flow-yield-vaults/admin/batch_relaunch_auto_balancers_and_schedule_supervisor.cdc new file mode 100644 index 00000000..532deaaf --- /dev/null +++ b/cadence/transactions/flow-yield-vaults/admin/batch_relaunch_auto_balancers_and_schedule_supervisor.cdc @@ -0,0 +1,188 @@ +import "Burner" +import "FungibleToken" +import "FungibleTokenConnectors" +import "FlowToken" +import "FlowTransactionScheduler" +import "DeFiActions" +import "AutoBalancers" + +import "FlowYieldVaultsAutoBalancersV1" +import "FlowYieldVaultsSchedulerV1" + +/// Relaunches a batch of AutoBalancers with a new recurring config and ensures the global Supervisor is scheduled. +/// +/// For each AutoBalancer ID: +/// - If the AutoBalancer is stuck, its inactive scheduled transaction records are left in place and a new rebalance +/// is seeded immediately after applying the new recurring config. +/// - If the AutoBalancer is not stuck, any live scheduled transactions are cancelled first so the new config can take +/// effect immediately without duplicate scheduled executions. +/// +/// Supervisor behavior: +/// - If the Supervisor capability is missing or invalid, the Supervisor is reset and reconfigured. +/// - Regardless of prior state, any currently scheduled Supervisor run is cancelled and a new recurring Supervisor run +/// is scheduled with the provided settings. +/// +/// @param ids: The YieldVault / AutoBalancer IDs to relaunch +/// @param interval: The recurring interval for the AutoBalancers in seconds +/// @param priorityRaw: The AutoBalancer priority (0=High, 1=Medium, 2=Low) +/// @param executionEffort: The AutoBalancer execution effort estimate (1-9999) +/// @param forceRebalance: Whether the AutoBalancers should rebalance even when still within their threshold band +/// @param supervisorRecurringInterval: The Supervisor recurring interval in seconds +/// @param supervisorPriorityRaw: The Supervisor priority (0=High, 1=Medium, 2=Low) +/// @param supervisorExecutionEffort: The Supervisor execution effort estimate (1-9999) +/// @param supervisorScanForStuck: Whether the Supervisor should scan for stuck yield vaults on each run +transaction( + ids: [UInt64], + interval: UInt64, + priorityRaw: UInt8, + executionEffort: UInt64, + forceRebalance: Bool, + supervisorRecurringInterval: UFix64, + supervisorPriorityRaw: UInt8, + supervisorExecutionEffort: UInt64, + supervisorScanForStuck: Bool +) { + let autoBalancers: [auth(DeFiActions.Identify, AutoBalancers.Configure, AutoBalancers.Schedule, FlowTransactionScheduler.Cancel) &AutoBalancers.AutoBalancer] + let autoBalancerIDs: [UInt64] + let fundingVault: Capability + let refundReceiver: &{FungibleToken.Vault} + let oldSupervisor: @FlowYieldVaultsSchedulerV1.Supervisor? + let supervisor: auth(FlowYieldVaultsSchedulerV1.Schedule) &FlowYieldVaultsSchedulerV1.Supervisor + + prepare(signer: auth(BorrowValue, CopyValue, LoadValue, StorageCapabilities) &Account) { + pre { + interval > 0: "interval must be greater than 0" + executionEffort > 0: "executionEffort must be greater than 0" + supervisorRecurringInterval > 0.0: "supervisorRecurringInterval must be greater than 0" + supervisorExecutionEffort > 0: "supervisorExecutionEffort must be greater than 0" + } + + self.autoBalancers = [] + self.autoBalancerIDs = [] + + var seen: {UInt64: Bool} = {} + for id in ids { + if seen[id] == true { + continue + } + seen[id] = true + + let storagePath = FlowYieldVaultsAutoBalancersV1.deriveAutoBalancerPath(id: id, storage: true) as! StoragePath + let autoBalancer = signer.storage + .borrow(from: storagePath) + ?? panic("Could not borrow AutoBalancer id \(id) at path \(storagePath)") + + self.autoBalancers.append(autoBalancer) + self.autoBalancerIDs.append(id) + } + + self.fundingVault = signer.storage + .copy>(from: /storage/strategiesFeeSource) + ?? panic("Could not find funding vault Capability at /storage/strategiesFeeSource") + + self.refundReceiver = signer.storage + .borrow<&{FungibleToken.Vault}>(from: /storage/flowTokenVault) + ?? panic("Refund receiver was not found in signer's storage at /storage/flowTokenVault") + + let supervisorCapabilityStoragePath = /storage/FlowYieldVaultsSupervisorCapability + let supervisorStoragePath = FlowYieldVaultsSchedulerV1.SupervisorStoragePath + + let supervisorExists = signer.storage.type(at: supervisorStoragePath) != nil + let storedSupervisorCap = signer.storage + .copy>( + from: supervisorCapabilityStoragePath + ) + var oldSupervisor: @FlowYieldVaultsSchedulerV1.Supervisor? <- nil + + if storedSupervisorCap != nil && !storedSupervisorCap!.check() { + let _ = signer.storage + .load>( + from: supervisorCapabilityStoragePath + ) + + for controller in signer.capabilities.storage.getControllers(forPath: supervisorStoragePath) { + controller.delete() + } + + if supervisorExists { + oldSupervisor <-! signer.storage.load<@FlowYieldVaultsSchedulerV1.Supervisor>(from: supervisorStoragePath) + } + + if let supervisorRef = &oldSupervisor as auth(FlowYieldVaultsSchedulerV1.Schedule) &FlowYieldVaultsSchedulerV1.Supervisor? { + Burner.burn(<-supervisorRef.cancelScheduledTransaction(refundReceiver: nil)) + } + } + + self.oldSupervisor <- oldSupervisor + + FlowYieldVaultsSchedulerV1.ensureSupervisorConfigured() + + self.supervisor = signer.storage + .borrow(from: supervisorStoragePath) + ?? panic("Could not borrow Supervisor at \(supervisorStoragePath)") + } + + execute { + let priority = FlowTransactionScheduler.Priority(rawValue: priorityRaw) + ?? panic("Invalid AutoBalancer priority: \(priorityRaw) - must be 0=High, 1=Medium, 2=Low") + let supervisorPriority = FlowTransactionScheduler.Priority(rawValue: supervisorPriorityRaw) + ?? panic("Invalid Supervisor priority: \(supervisorPriorityRaw) - must be 0=High, 1=Medium, 2=Low") + + var index = 0 + while index < self.autoBalancers.length { + let id = self.autoBalancerIDs[index] + let autoBalancer = self.autoBalancers[index] + let isStuck = FlowYieldVaultsAutoBalancersV1.isStuckYieldVault(id: id) + + if !isStuck { + for txnID in autoBalancer.getScheduledTransactionIDs() { + let txn = autoBalancer.borrowScheduledTransaction(id: txnID) + if txn?.status() == FlowTransactionScheduler.Status.Scheduled { + if let refund <- autoBalancer.cancelScheduledTransaction(id: txnID) as @{FungibleToken.Vault}? { + self.refundReceiver.deposit(from: <-refund) + } + } + } + } + + var txnFunder = FungibleTokenConnectors.VaultSinkAndSource( + min: nil, + max: nil, + vault: self.fundingVault, + uniqueID: nil + ) + + DeFiActions.alignID( + toUpdate: &txnFunder as auth(DeFiActions.Extend) &{DeFiActions.IdentifiableStruct}, + with: autoBalancer + ) + + let config = AutoBalancers.AutoBalancerRecurringConfig( + interval: interval, + priority: priority, + executionEffort: executionEffort, + forceRebalance: forceRebalance, + txnFunder: txnFunder + ) + + autoBalancer.setRecurringConfig(config) + + if let err = autoBalancer.scheduleNextRebalance(whileExecuting: nil) { + panic("Failed to schedule next rebalance for AutoBalancer \(id): \(err)") + } + + index = index + 1 + } + + Burner.burn(<-self.supervisor.cancelScheduledTransaction(refundReceiver: nil)) + + self.supervisor.scheduleNextRecurringExecution( + recurringInterval: supervisorRecurringInterval, + priority: supervisorPriority, + executionEffort: supervisorExecutionEffort, + scanForStuck: supervisorScanForStuck + ) + + Burner.burn(<-self.oldSupervisor) + } +} From 6df05b7d1e214032a87c2555c74d65d8bcad9fe5 Mon Sep 17 00:00:00 2001 From: liobrasil Date: Fri, 3 Apr 2026 07:01:31 -0400 Subject: [PATCH 2/5] avoid panics in batch auto-balancer relaunch tx --- ...h_auto_balancers_and_schedule_supervisor.cdc | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/cadence/transactions/flow-yield-vaults/admin/batch_relaunch_auto_balancers_and_schedule_supervisor.cdc b/cadence/transactions/flow-yield-vaults/admin/batch_relaunch_auto_balancers_and_schedule_supervisor.cdc index 532deaaf..418c5b06 100644 --- a/cadence/transactions/flow-yield-vaults/admin/batch_relaunch_auto_balancers_and_schedule_supervisor.cdc +++ b/cadence/transactions/flow-yield-vaults/admin/batch_relaunch_auto_balancers_and_schedule_supervisor.cdc @@ -63,6 +63,7 @@ transaction( var seen: {UInt64: Bool} = {} for id in ids { if seen[id] == true { + log("Skipping duplicate AutoBalancer id \(id)") continue } seen[id] = true @@ -70,9 +71,12 @@ transaction( let storagePath = FlowYieldVaultsAutoBalancersV1.deriveAutoBalancerPath(id: id, storage: true) as! StoragePath let autoBalancer = signer.storage .borrow(from: storagePath) - ?? panic("Could not borrow AutoBalancer id \(id) at path \(storagePath)") + if autoBalancer == nil { + log("Skipping missing AutoBalancer id \(id) at path \(storagePath)") + continue + } - self.autoBalancers.append(autoBalancer) + self.autoBalancers.append(autoBalancer!) self.autoBalancerIDs.append(id) } @@ -135,14 +139,19 @@ transaction( let isStuck = FlowYieldVaultsAutoBalancersV1.isStuckYieldVault(id: id) if !isStuck { + var cancelledCount = 0 for txnID in autoBalancer.getScheduledTransactionIDs() { let txn = autoBalancer.borrowScheduledTransaction(id: txnID) if txn?.status() == FlowTransactionScheduler.Status.Scheduled { if let refund <- autoBalancer.cancelScheduledTransaction(id: txnID) as @{FungibleToken.Vault}? { self.refundReceiver.deposit(from: <-refund) } + cancelledCount = cancelledCount + 1 } } + if cancelledCount > 0 { + log("Cancelled \(cancelledCount) scheduled transaction(s) for AutoBalancer \(id)") + } } var txnFunder = FungibleTokenConnectors.VaultSinkAndSource( @@ -168,7 +177,9 @@ transaction( autoBalancer.setRecurringConfig(config) if let err = autoBalancer.scheduleNextRebalance(whileExecuting: nil) { - panic("Failed to schedule next rebalance for AutoBalancer \(id): \(err)") + log("Failed to schedule next rebalance for AutoBalancer \(id): \(err)") + index = index + 1 + continue } index = index + 1 From a9949b1b82b79aedd9c01ac7ba570f1a3b75793a Mon Sep 17 00:00:00 2001 From: liobrasil Date: Fri, 3 Apr 2026 08:07:44 -0400 Subject: [PATCH 3/5] harden batch relaunch tx and add integration tests --- ...balancers_and_schedule_supervisor_test.cdc | 380 ++++++++++++++++++ ...auto_balancers_and_schedule_supervisor.cdc | 19 +- 2 files changed, 395 insertions(+), 4 deletions(-) create mode 100644 cadence/tests/batch_relaunch_auto_balancers_and_schedule_supervisor_test.cdc diff --git a/cadence/tests/batch_relaunch_auto_balancers_and_schedule_supervisor_test.cdc b/cadence/tests/batch_relaunch_auto_balancers_and_schedule_supervisor_test.cdc new file mode 100644 index 00000000..1f9b3913 --- /dev/null +++ b/cadence/tests/batch_relaunch_auto_balancers_and_schedule_supervisor_test.cdc @@ -0,0 +1,380 @@ +import Test +import BlockchainHelpers + +import "test_helpers.cdc" + +import "FlowToken" +import "MOET" +import "YieldToken" +import "MockStrategies" +import "FlowYieldVaultsSchedulerV1" +import "FlowTransactionScheduler" +import "AutoBalancers" + +access(all) let protocolAccount = Test.getAccount(0x0000000000000008) +access(all) let flowYieldVaultsAccount = Test.getAccount(0x0000000000000009) +access(all) let yieldTokenAccount = Test.getAccount(0x0000000000000010) + +access(all) var strategyIdentifier = Type<@MockStrategies.TracerStrategy>().identifier +access(all) var flowTokenIdentifier = Type<@FlowToken.Vault>().identifier +access(all) var yieldTokenIdentifier = Type<@YieldToken.Vault>().identifier +access(all) var moetTokenIdentifier = Type<@MOET.Vault>().identifier +access(all) var snapshot: UInt64 = 0 + +access(all) +fun setup() { + log("Setting up batch relaunch + supervisor integration test...") + + deployContracts() + let fundingFlowYieldVaultsRes = mintFlow(to: flowYieldVaultsAccount, amount: 1000.0) + Test.expect(fundingFlowYieldVaultsRes, Test.beSucceeded()) + + setMockOraclePrice(signer: flowYieldVaultsAccount, forTokenIdentifier: yieldTokenIdentifier, price: 1.0) + setMockOraclePrice(signer: flowYieldVaultsAccount, forTokenIdentifier: flowTokenIdentifier, price: 1.0) + + let reserveAmount = 100_000_00.0 + setupMoetVault(protocolAccount, beFailed: false) + setupYieldVault(protocolAccount, beFailed: false) + let fundingProtocolRes = mintFlow(to: protocolAccount, amount: reserveAmount) + Test.expect(fundingProtocolRes, Test.beSucceeded()) + mintMoet(signer: protocolAccount, to: protocolAccount.address, amount: reserveAmount, beFailed: false) + mintYield(signer: yieldTokenAccount, to: protocolAccount.address, amount: reserveAmount, beFailed: false) + setMockSwapperLiquidityConnector(signer: protocolAccount, vaultStoragePath: MOET.VaultStoragePath) + setMockSwapperLiquidityConnector(signer: protocolAccount, vaultStoragePath: YieldToken.VaultStoragePath) + setMockSwapperLiquidityConnector(signer: protocolAccount, vaultStoragePath: /storage/flowTokenVault) + + createAndStorePool(signer: protocolAccount, defaultTokenIdentifier: moetTokenIdentifier, beFailed: false) + addSupportedTokenFixedRateInterestCurve( + signer: protocolAccount, + tokenTypeIdentifier: flowTokenIdentifier, + collateralFactor: 0.8, + borrowFactor: 1.0, + yearlyRate: 0.1, + depositRate: 1_000_000.0, + depositCapacityCap: 1_000_000.0 + ) + + let openRes = executeTransaction( + "../../lib/FlowALP/cadence/transactions/flow-alp/position/create_position.cdc", + [reserveAmount / 2.0, /storage/flowTokenVault, true], + protocolAccount + ) + Test.expect(openRes, Test.beSucceeded()) + + addStrategyComposer( + signer: flowYieldVaultsAccount, + strategyIdentifier: strategyIdentifier, + composerIdentifier: Type<@MockStrategies.TracerStrategyComposer>().identifier, + issuerStoragePath: MockStrategies.IssuerStoragePath, + beFailed: false + ) + + snapshot = getCurrentBlockHeight() + Test.commitBlock() + log("Setup complete") +} + +access(all) +fun createYieldVaults(user: Test.TestAccount, count: Int, amount: UFix64): [UInt64] { + let before = getYieldVaultIDs(address: user.address) ?? [] + + var idx = 0 + while idx < count { + let createRes = executeTransaction( + "../transactions/flow-yield-vaults/create_yield_vault.cdc", + [strategyIdentifier, flowTokenIdentifier, amount], + user + ) + Test.expect(createRes, Test.beSucceeded()) + idx = idx + 1 + } + + let after = getYieldVaultIDs(address: user.address)! + let newIDs: [UInt64] = [] + for id in after { + if !before.contains(id) { + newIDs.append(id) + } + } + + Test.assertEqual(count, newIDs.length) + return newIDs +} + +access(all) +fun hasActiveSchedule(_ yieldVaultID: UInt64): Bool { + let res = executeScript("../scripts/flow-yield-vaults/has_active_schedule.cdc", [yieldVaultID]) + Test.expect(res, Test.beSucceeded()) + return res.returnValue! as! Bool +} + +access(all) +fun isStuckYieldVault(_ yieldVaultID: UInt64): Bool { + let res = executeScript("../scripts/flow-yield-vaults/is_stuck_yield_vault.cdc", [yieldVaultID]) + Test.expect(res, Test.beSucceeded()) + return res.returnValue! as! Bool +} + +access(all) +fun getPendingCount(): Int { + let res = executeScript("../scripts/flow-yield-vaults/get_pending_count.cdc", []) + Test.expect(res, Test.beSucceeded()) + return res.returnValue! as! Int +} + +access(all) +fun getFlowYieldVaultsFlowBalance(): UFix64 { + let res = executeScript( + "../scripts/flow-yield-vaults/get_flow_balance.cdc", + [flowYieldVaultsAccount.address] + ) + Test.expect(res, Test.beSucceeded()) + return res.returnValue! as! UFix64 +} + +access(all) +fun drainFlowToResidual(_ residualBalance: UFix64) { + let balanceBeforeDrain = getFlowYieldVaultsFlowBalance() + if balanceBeforeDrain > residualBalance { + let drainRes = executeTransaction( + "../transactions/flow-yield-vaults/drain_flow.cdc", + [balanceBeforeDrain - residualBalance], + flowYieldVaultsAccount + ) + Test.expect(drainRes, Test.beSucceeded()) + } +} + +access(all) +fun waitUntilAllStuck(_ ids: [UInt64], maxRounds: Int): Bool { + var round = 0 + while round < maxRounds { + var allStuck = true + for id in ids { + if !isStuckYieldVault(id) { + allStuck = false + } + } + if allStuck { + return true + } + + Test.moveTime(by: 60.0 * 10.0 + 10.0) + Test.commitBlock() + round = round + 1 + } + + var finalAllStuck = true + for id in ids { + if !isStuckYieldVault(id) { + finalAllStuck = false + } + } + return finalAllStuck +} + +access(all) +fun countRebalancedEventsFor(_ yieldVaultID: UInt64): Int { + var count = 0 + let events = Test.eventsOfType(Type()) + for eventAny in events { + let rebalanceEvent = eventAny as! AutoBalancers.Rebalanced + if rebalanceEvent.uniqueID == yieldVaultID { + count = count + 1 + } + } + return count +} + +access(all) +fun scheduleSupervisor( + recurringInterval: UFix64, + priorityRaw: UInt8, + executionEffort: UInt64, + scanForStuck: Bool +) { + let res = executeTransaction( + "../transactions/flow-yield-vaults/admin/schedule_supervisor.cdc", + [recurringInterval, priorityRaw, executionEffort, scanForStuck], + flowYieldVaultsAccount + ) + Test.expect(res, Test.beSucceeded()) +} + +access(all) +fun batchRelaunch( + ids: [UInt64], + interval: UInt64, + priorityRaw: UInt8, + executionEffort: UInt64, + forceRebalance: Bool, + supervisorRecurringInterval: UFix64, + supervisorPriorityRaw: UInt8, + supervisorExecutionEffort: UInt64, + supervisorScanForStuck: Bool +): Test.TransactionResult { + return executeTransaction( + "../transactions/flow-yield-vaults/admin/batch_relaunch_auto_balancers_and_schedule_supervisor.cdc", + [ + ids, + interval, + priorityRaw, + executionEffort, + forceRebalance, + supervisorRecurringInterval, + supervisorPriorityRaw, + supervisorExecutionEffort, + supervisorScanForStuck + ], + flowYieldVaultsAccount + ) +} + +access(all) +fun testBatchRelaunchHandlesMixedPopulationAndRunningSupervisor() { + Test.reset(to: snapshot) + log("\n[TEST] Batch relaunch handles mixed stuck + active vaults with a running supervisor...") + + let user = Test.createAccount() + let initialUserFundingRes = mintFlow(to: user, amount: 5_000.0) + Test.expect(initialUserFundingRes, Test.beSucceeded()) + let grantBetaRes = grantBeta(flowYieldVaultsAccount, user) + Test.expect(grantBetaRes, Test.beSucceeded()) + + let stuckIDs = createYieldVaults(user: user, count: 3, amount: 25.0) + drainFlowToResidual(0.001) + + Test.assert(waitUntilAllStuck(stuckIDs, maxRounds: 8), message: "Expected initial vaults to become stuck") + for id in stuckIDs { + Test.assertEqual(true, isStuckYieldVault(id)) + Test.assertEqual(false, hasActiveSchedule(id)) + } + + let restockFlowYieldVaultsRes = mintFlow(to: flowYieldVaultsAccount, amount: 500.0) + Test.expect(restockFlowYieldVaultsRes, Test.beSucceeded()) + + let activeIDs = createYieldVaults(user: user, count: 7, amount: 25.0) + for id in activeIDs { + Test.assertEqual(false, isStuckYieldVault(id)) + Test.assertEqual(true, hasActiveSchedule(id)) + } + + let supervisorRescheduledBefore = Test.eventsOfType(Type()).length + scheduleSupervisor(recurringInterval: 300.0, priorityRaw: 1, executionEffort: 2000, scanForStuck: false) + Test.moveTime(by: 300.0 + 10.0) + Test.commitBlock() + let supervisorRescheduledAfterWarmup = Test.eventsOfType(Type()).length + Test.assert( + supervisorRescheduledAfterWarmup >= supervisorRescheduledBefore + 2, + message: "Supervisor should schedule and then self-reschedule during warmup" + ) + + for id in stuckIDs { + Test.assertEqual(true, isStuckYieldVault(id)) + } + + let activeProbe = activeIDs[0] + let activeProbeRebalancedBefore = countRebalancedEventsFor(activeProbe) + + let idsForBatch = stuckIDs.concat(activeIDs).concat([activeProbe, 999_999]) + let batchRes = batchRelaunch( + ids: idsForBatch, + interval: 1800, + priorityRaw: 1, + executionEffort: 1200, + forceRebalance: false, + supervisorRecurringInterval: 900.0, + supervisorPriorityRaw: 1, + supervisorExecutionEffort: 5000, + supervisorScanForStuck: true + ) + Test.expect(batchRes, Test.beSucceeded()) + + let allValidIDs = stuckIDs.concat(activeIDs) + for id in allValidIDs { + Test.assertEqual(false, isStuckYieldVault(id)) + Test.assertEqual(true, hasActiveSchedule(id)) + } + Test.assertEqual(0, getPendingCount()) + + setMockOraclePrice(signer: flowYieldVaultsAccount, forTokenIdentifier: flowTokenIdentifier, price: 5.0) + setMockOraclePrice(signer: flowYieldVaultsAccount, forTokenIdentifier: yieldTokenIdentifier, price: 4.0) + + Test.moveTime(by: 600.0 + 10.0) + Test.commitBlock() + let activeProbeRebalancedMidway = countRebalancedEventsFor(activeProbe) + Test.assert( + activeProbeRebalancedBefore == activeProbeRebalancedMidway, + message: "Active vault should not execute again before the new 1800s interval" + ) + + Test.moveTime(by: 1200.0 + 10.0) + Test.commitBlock() + let activeProbeRebalancedAfter = countRebalancedEventsFor(activeProbe) + Test.assert( + activeProbeRebalancedAfter > activeProbeRebalancedMidway, + message: "Active vault should execute after the new 1800s interval elapses" + ) +} + +access(all) +fun testBatchRelaunchRecreatesSupervisorWhenDestroyed() { + Test.reset(to: snapshot) + log("\n[TEST] Batch relaunch recreates and reschedules the supervisor when it is destroyed...") + + let user = Test.createAccount() + let recreateUserFundingRes = mintFlow(to: user, amount: 500.0) + Test.expect(recreateUserFundingRes, Test.beSucceeded()) + let recreateGrantBetaRes = grantBeta(flowYieldVaultsAccount, user) + Test.expect(recreateGrantBetaRes, Test.beSucceeded()) + + let stuckIDs = createYieldVaults(user: user, count: 3, amount: 25.0) + drainFlowToResidual(0.001) + Test.assert(waitUntilAllStuck(stuckIDs, maxRounds: 10), message: "Expected test vaults to become stuck") + + let refillFlowYieldVaultsRes = mintFlow(to: flowYieldVaultsAccount, amount: 200.0) + Test.expect(refillFlowYieldVaultsRes, Test.beSucceeded()) + + let destroySupervisorRes = executeTransaction( + "../transactions/flow-yield-vaults/admin/destroy_supervisor.cdc", + [], + flowYieldVaultsAccount + ) + Test.expect(destroySupervisorRes, Test.beSucceeded()) + + let supervisorRescheduledBefore = Test.eventsOfType(Type()).length + + let batchRes = batchRelaunch( + ids: stuckIDs, + interval: 1800, + priorityRaw: 1, + executionEffort: 1200, + forceRebalance: false, + supervisorRecurringInterval: 900.0, + supervisorPriorityRaw: 1, + supervisorExecutionEffort: 5000, + supervisorScanForStuck: true + ) + Test.expect(batchRes, Test.beSucceeded()) + + for yieldVaultID in stuckIDs { + Test.assertEqual(false, isStuckYieldVault(yieldVaultID)) + Test.assertEqual(true, hasActiveSchedule(yieldVaultID)) + } + Test.assertEqual(0, getPendingCount()) + + let supervisorRescheduledAfterBatch = Test.eventsOfType(Type()).length + Test.assert( + supervisorRescheduledAfterBatch == supervisorRescheduledBefore + 1, + message: "Batch relaunch should schedule a fresh supervisor run" + ) + + Test.moveTime(by: 900.0 + 10.0) + Test.commitBlock() + + let supervisorRescheduledAfterExecution = Test.eventsOfType(Type()).length + Test.assert( + supervisorRescheduledAfterExecution > supervisorRescheduledAfterBatch, + message: "Supervisor should execute and self-reschedule after being recreated" + ) +} diff --git a/cadence/transactions/flow-yield-vaults/admin/batch_relaunch_auto_balancers_and_schedule_supervisor.cdc b/cadence/transactions/flow-yield-vaults/admin/batch_relaunch_auto_balancers_and_schedule_supervisor.cdc index 418c5b06..7260fa3d 100644 --- a/cadence/transactions/flow-yield-vaults/admin/batch_relaunch_auto_balancers_and_schedule_supervisor.cdc +++ b/cadence/transactions/flow-yield-vaults/admin/batch_relaunch_auto_balancers_and_schedule_supervisor.cdc @@ -49,7 +49,7 @@ transaction( let oldSupervisor: @FlowYieldVaultsSchedulerV1.Supervisor? let supervisor: auth(FlowYieldVaultsSchedulerV1.Schedule) &FlowYieldVaultsSchedulerV1.Supervisor - prepare(signer: auth(BorrowValue, CopyValue, LoadValue, StorageCapabilities) &Account) { + prepare(signer: auth(BorrowValue, CopyValue, LoadValue, SaveValue, StorageCapabilities) &Account) { pre { interval > 0: "interval must be greater than 0" executionEffort > 0: "executionEffort must be greater than 0" @@ -80,9 +80,20 @@ transaction( self.autoBalancerIDs.append(id) } - self.fundingVault = signer.storage - .copy>(from: /storage/strategiesFeeSource) - ?? panic("Could not find funding vault Capability at /storage/strategiesFeeSource") + let fundingVaultStoragePath = /storage/strategiesFeeSource + var fundingVault = signer.storage + .copy>(from: fundingVaultStoragePath) + + if fundingVault == nil { + let issuedFundingVault = signer.capabilities.storage + .issue(/storage/flowTokenVault) + signer.storage.save(issuedFundingVault, to: fundingVaultStoragePath) + fundingVault = signer.storage + .copy>(from: fundingVaultStoragePath) + } + + self.fundingVault = fundingVault + ?? panic("Could not find or create funding vault Capability at /storage/strategiesFeeSource") self.refundReceiver = signer.storage .borrow<&{FungibleToken.Vault}>(from: /storage/flowTokenVault) From 7a55c1e8b0443ae205caf3b2748d358a1c9ca90d Mon Sep 17 00:00:00 2001 From: liobrasil Date: Fri, 3 Apr 2026 08:11:40 -0400 Subject: [PATCH 4/5] document and harden batch relaunch flow --- ...auto_balancers_and_schedule_supervisor_test.cdc | 10 ++++++++++ ...unch_auto_balancers_and_schedule_supervisor.cdc | 14 +++++++++++++- 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/cadence/tests/batch_relaunch_auto_balancers_and_schedule_supervisor_test.cdc b/cadence/tests/batch_relaunch_auto_balancers_and_schedule_supervisor_test.cdc index 1f9b3913..b938a5df 100644 --- a/cadence/tests/batch_relaunch_auto_balancers_and_schedule_supervisor_test.cdc +++ b/cadence/tests/batch_relaunch_auto_balancers_and_schedule_supervisor_test.cdc @@ -26,6 +26,8 @@ fun setup() { log("Setting up batch relaunch + supervisor integration test...") deployContracts() + // Intentionally fund the account without creating /storage/strategiesFeeSource. + // The batch relaunch transaction is expected to self-heal that capability. let fundingFlowYieldVaultsRes = mintFlow(to: flowYieldVaultsAccount, amount: 1000.0) Test.expect(fundingFlowYieldVaultsRes, Test.beSucceeded()) @@ -159,6 +161,8 @@ fun waitUntilAllStuck(_ ids: [UInt64], maxRounds: Int): Bool { return true } + // Each mock auto-balancer is configured for a 10 minute cadence on creation, so advance slightly + // past that boundary to let failed reschedule attempts surface as stuck state. Test.moveTime(by: 60.0 * 10.0 + 10.0) Test.commitBlock() round = round + 1 @@ -242,6 +246,7 @@ fun testBatchRelaunchHandlesMixedPopulationAndRunningSupervisor() { Test.expect(grantBetaRes, Test.beSucceeded()) let stuckIDs = createYieldVaults(user: user, count: 3, amount: 25.0) + // Drain the scheduler fee balance so the first population loses its ability to keep self-scheduling. drainFlowToResidual(0.001) Test.assert(waitUntilAllStuck(stuckIDs, maxRounds: 8), message: "Expected initial vaults to become stuck") @@ -253,6 +258,7 @@ fun testBatchRelaunchHandlesMixedPopulationAndRunningSupervisor() { let restockFlowYieldVaultsRes = mintFlow(to: flowYieldVaultsAccount, amount: 500.0) Test.expect(restockFlowYieldVaultsRes, Test.beSucceeded()) + // Create a second healthy population after restoring FLOW so the batch contains both active and stuck IDs. let activeIDs = createYieldVaults(user: user, count: 7, amount: 25.0) for id in activeIDs { Test.assertEqual(false, isStuckYieldVault(id)) @@ -260,6 +266,7 @@ fun testBatchRelaunchHandlesMixedPopulationAndRunningSupervisor() { } let supervisorRescheduledBefore = Test.eventsOfType(Type()).length + // Warm up a live supervisor run before invoking the batch transaction so we cover the "already running" case. scheduleSupervisor(recurringInterval: 300.0, priorityRaw: 1, executionEffort: 2000, scanForStuck: false) Test.moveTime(by: 300.0 + 10.0) Test.commitBlock() @@ -276,6 +283,7 @@ fun testBatchRelaunchHandlesMixedPopulationAndRunningSupervisor() { let activeProbe = activeIDs[0] let activeProbeRebalancedBefore = countRebalancedEventsFor(activeProbe) + // Include both a duplicate and a missing ID to verify that the batch skips them without reverting. let idsForBatch = stuckIDs.concat(activeIDs).concat([activeProbe, 999_999]) let batchRes = batchRelaunch( ids: idsForBatch, @@ -297,6 +305,7 @@ fun testBatchRelaunchHandlesMixedPopulationAndRunningSupervisor() { } Test.assertEqual(0, getPendingCount()) + // Force a real threshold breach so the post-relaunch execution produces a Rebalanced event rather than a no-op. setMockOraclePrice(signer: flowYieldVaultsAccount, forTokenIdentifier: flowTokenIdentifier, price: 5.0) setMockOraclePrice(signer: flowYieldVaultsAccount, forTokenIdentifier: yieldTokenIdentifier, price: 4.0) @@ -344,6 +353,7 @@ fun testBatchRelaunchRecreatesSupervisorWhenDestroyed() { let supervisorRescheduledBefore = Test.eventsOfType(Type()).length + // The batch transaction should both recover the vault schedules and bootstrap a fresh supervisor run. let batchRes = batchRelaunch( ids: stuckIDs, interval: 1800, diff --git a/cadence/transactions/flow-yield-vaults/admin/batch_relaunch_auto_balancers_and_schedule_supervisor.cdc b/cadence/transactions/flow-yield-vaults/admin/batch_relaunch_auto_balancers_and_schedule_supervisor.cdc index 7260fa3d..89ad4c06 100644 --- a/cadence/transactions/flow-yield-vaults/admin/batch_relaunch_auto_balancers_and_schedule_supervisor.cdc +++ b/cadence/transactions/flow-yield-vaults/admin/batch_relaunch_auto_balancers_and_schedule_supervisor.cdc @@ -62,6 +62,7 @@ transaction( var seen: {UInt64: Bool} = {} for id in ids { + // A batch run should tolerate duplicates instead of making the caller sanitize the list first. if seen[id] == true { log("Skipping duplicate AutoBalancer id \(id)") continue @@ -71,6 +72,7 @@ transaction( let storagePath = FlowYieldVaultsAutoBalancersV1.deriveAutoBalancerPath(id: id, storage: true) as! StoragePath let autoBalancer = signer.storage .borrow(from: storagePath) + // Missing IDs are logged and skipped so the rest of the batch can still be repaired. if autoBalancer == nil { log("Skipping missing AutoBalancer id \(id) at path \(storagePath)") continue @@ -84,7 +86,11 @@ transaction( var fundingVault = signer.storage .copy>(from: fundingVaultStoragePath) - if fundingVault == nil { + // Admin accounts on older deployments may not have the fee-source capability saved yet, or may + // hold a stale one. Rebuild it from /storage/flowTokenVault so the batch transaction is self-healing. + if fundingVault == nil || !fundingVault!.check() { + let _ = signer.storage + .load>(from: fundingVaultStoragePath) let issuedFundingVault = signer.capabilities.storage .issue(/storage/flowTokenVault) signer.storage.save(issuedFundingVault, to: fundingVaultStoragePath) @@ -109,6 +115,8 @@ transaction( ) var oldSupervisor: @FlowYieldVaultsSchedulerV1.Supervisor? <- nil + // If the saved capability is stale, remove the broken capability state and rebuild the resource + // via ensureSupervisorConfigured() below. if storedSupervisorCap != nil && !storedSupervisorCap!.check() { let _ = signer.storage .load>( @@ -153,6 +161,8 @@ transaction( var cancelledCount = 0 for txnID in autoBalancer.getScheduledTransactionIDs() { let txn = autoBalancer.borrowScheduledTransaction(id: txnID) + // Only live Scheduled entries are canceled. Stuck vaults keep their historical records and + // simply receive a new seeded schedule. if txn?.status() == FlowTransactionScheduler.Status.Scheduled { if let refund <- autoBalancer.cancelScheduledTransaction(id: txnID) as @{FungibleToken.Vault}? { self.refundReceiver.deposit(from: <-refund) @@ -187,6 +197,7 @@ transaction( autoBalancer.setRecurringConfig(config) + // A single AutoBalancer that still cannot be scheduled should not abort the whole repair batch. if let err = autoBalancer.scheduleNextRebalance(whileExecuting: nil) { log("Failed to schedule next rebalance for AutoBalancer \(id): \(err)") index = index + 1 @@ -196,6 +207,7 @@ transaction( index = index + 1 } + // Always replace the supervisor run so the batch leaves a single authoritative recurring schedule behind. Burner.burn(<-self.supervisor.cancelScheduledTransaction(refundReceiver: nil)) self.supervisor.scheduleNextRecurringExecution( From f4f30676d944313d94694fcdd031fb517de237d6 Mon Sep 17 00:00:00 2001 From: liobrasil Date: Fri, 3 Apr 2026 08:13:40 -0400 Subject: [PATCH 5/5] remove concat usage from batch relaunch test --- ..._balancers_and_schedule_supervisor_test.cdc | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/cadence/tests/batch_relaunch_auto_balancers_and_schedule_supervisor_test.cdc b/cadence/tests/batch_relaunch_auto_balancers_and_schedule_supervisor_test.cdc index b938a5df..c7082382 100644 --- a/cadence/tests/batch_relaunch_auto_balancers_and_schedule_supervisor_test.cdc +++ b/cadence/tests/batch_relaunch_auto_balancers_and_schedule_supervisor_test.cdc @@ -284,7 +284,15 @@ fun testBatchRelaunchHandlesMixedPopulationAndRunningSupervisor() { let activeProbeRebalancedBefore = countRebalancedEventsFor(activeProbe) // Include both a duplicate and a missing ID to verify that the batch skips them without reverting. - let idsForBatch = stuckIDs.concat(activeIDs).concat([activeProbe, 999_999]) + let idsForBatch: [UInt64] = [] + for id in stuckIDs { + idsForBatch.append(id) + } + for id in activeIDs { + idsForBatch.append(id) + } + idsForBatch.append(activeProbe) + idsForBatch.append(999_999) let batchRes = batchRelaunch( ids: idsForBatch, interval: 1800, @@ -298,7 +306,13 @@ fun testBatchRelaunchHandlesMixedPopulationAndRunningSupervisor() { ) Test.expect(batchRes, Test.beSucceeded()) - let allValidIDs = stuckIDs.concat(activeIDs) + let allValidIDs: [UInt64] = [] + for id in stuckIDs { + allValidIDs.append(id) + } + for id in activeIDs { + allValidIDs.append(id) + } for id in allValidIDs { Test.assertEqual(false, isStuckYieldVault(id)) Test.assertEqual(true, hasActiveSchedule(id))