|
| 1 | +// standards |
| 2 | +import "Burner" |
| 3 | +import "FungibleToken" |
| 4 | +// DeFiActions |
| 5 | +import "DeFiActions" |
| 6 | +import "AutoBalancers" |
| 7 | +import "FlowTransactionScheduler" |
| 8 | +// Registry for global yield vault mapping |
| 9 | +import "FlowYieldVaultsSchedulerRegistryV1" |
| 10 | + |
| 11 | +/// FlowYieldVaultsAutoBalancersV1 |
| 12 | +/// |
| 13 | +/// This contract deals with the storage, retrieval and cleanup of DeFiActions AutoBalancers as they are used in |
| 14 | +/// FlowYieldVaults defined Strategies. |
| 15 | +/// |
| 16 | +/// AutoBalancers are stored in contract account storage at paths derived by their related DeFiActions.UniqueIdentifier.id |
| 17 | +/// which identifies all DeFiActions components in the stack related to their composite Strategy. |
| 18 | +/// |
| 19 | +/// When a YieldVault and necessarily the related Strategy is closed & burned, the related AutoBalancer and its Capabilities |
| 20 | +/// are destroyed and deleted. |
| 21 | +/// |
| 22 | +/// Scheduling approach: |
| 23 | +/// - AutoBalancers are configured with a recurringConfig at creation |
| 24 | +/// - After creation, scheduleNextRebalance(nil) starts the self-scheduling chain |
| 25 | +/// - The registry tracks all live yield vault IDs for global mapping |
| 26 | +/// - Cleanup unregisters from the registry |
| 27 | +/// |
| 28 | +access(all) contract FlowYieldVaultsAutoBalancersV1 { |
| 29 | + |
| 30 | + /// The path prefix used for StoragePath & PublicPath derivations |
| 31 | + access(all) let pathPrefix: String |
| 32 | + |
| 33 | + /// Storage path for the shared execution callback resource that reports to the registry (one per account) |
| 34 | + access(self) let registryReportCallbackStoragePath: StoragePath |
| 35 | + |
| 36 | + /// Callback resource invoked by each AutoBalancer after execution; calls Registry.reportExecution with its id |
| 37 | + access(all) resource RegistryReportCallback: AutoBalancers.AutoBalancerExecutionCallback { |
| 38 | + access(all) fun onExecuted(balancerUUID: UInt64) { |
| 39 | + FlowYieldVaultsSchedulerRegistryV1.reportExecution(yieldVaultID: balancerUUID) |
| 40 | + } |
| 41 | + } |
| 42 | + |
| 43 | + /* --- PUBLIC METHODS --- */ |
| 44 | + |
| 45 | + /// Returns the path (StoragePath or PublicPath) at which an AutoBalancer is stored with the associated |
| 46 | + /// UniqueIdentifier.id. |
| 47 | + access(all) view fun deriveAutoBalancerPath(id: UInt64, storage: Bool): Path { |
| 48 | + return storage ? StoragePath(identifier: "\(self.pathPrefix)\(id)")! : PublicPath(identifier: "\(self.pathPrefix)\(id)")! |
| 49 | + } |
| 50 | + |
| 51 | + /// Returns an unauthorized reference to an AutoBalancer with the given UniqueIdentifier.id value. If none is |
| 52 | + /// configured, `nil` will be returned. |
| 53 | + access(all) fun borrowAutoBalancer(id: UInt64): &AutoBalancers.AutoBalancer? { |
| 54 | + let publicPath = self.deriveAutoBalancerPath(id: id, storage: false) as! PublicPath |
| 55 | + return self.account.capabilities.borrow<&AutoBalancers.AutoBalancer>(publicPath) |
| 56 | + } |
| 57 | + |
| 58 | + /// Creates a source from an AutoBalancer for external use (e.g., position close operations). |
| 59 | + /// This allows bypassing position topUpSource to avoid circular dependency issues. |
| 60 | + /// |
| 61 | + /// @param id: The yield vault/AutoBalancer ID |
| 62 | + /// @return Source that can withdraw from the AutoBalancer, or nil if not found |
| 63 | + /// |
| 64 | + access(account) fun createExternalSource(id: UInt64): {DeFiActions.Source}? { |
| 65 | + let storagePath = self.deriveAutoBalancerPath(id: id, storage: true) as! StoragePath |
| 66 | + if let autoBalancer = self.account.storage.borrow<auth(AutoBalancers.Get) &AutoBalancers.AutoBalancer>(from: storagePath) { |
| 67 | + return autoBalancer.createBalancerSource() |
| 68 | + } |
| 69 | + return nil |
| 70 | + } |
| 71 | + |
| 72 | + /// Creates a sink to an AutoBalancer for external deposits (e.g., cancel deferred redemption). |
| 73 | + /// |
| 74 | + /// @param id: The yield vault/AutoBalancer ID |
| 75 | + /// @return Sink that can deposit to the AutoBalancer, or nil if not found |
| 76 | + /// |
| 77 | + access(account) fun createExternalSink(id: UInt64): {DeFiActions.Sink}? { |
| 78 | + let storagePath = self.deriveAutoBalancerPath(id: id, storage: true) as! StoragePath |
| 79 | + if let autoBalancer = self.account.storage.borrow<auth(AutoBalancers.Get) &AutoBalancers.AutoBalancer>(from: storagePath) { |
| 80 | + return autoBalancer.createBalancerSink() |
| 81 | + } |
| 82 | + return nil |
| 83 | + } |
| 84 | + |
| 85 | + /// Checks if an AutoBalancer has at least one active (Scheduled) transaction. |
| 86 | + /// Used by Supervisor to detect stuck yield vaults that need recovery. |
| 87 | + /// |
| 88 | + /// @param id: The yield vault/AutoBalancer ID |
| 89 | + /// @return Bool: true if there's at least one Scheduled transaction, false otherwise |
| 90 | + /// |
| 91 | + access(all) fun hasActiveSchedule(id: UInt64): Bool { |
| 92 | + let autoBalancer = self.borrowAutoBalancer(id: id) |
| 93 | + if autoBalancer == nil { |
| 94 | + return false |
| 95 | + } |
| 96 | + |
| 97 | + let txnIDs = autoBalancer!.getScheduledTransactionIDs() |
| 98 | + for txnID in txnIDs { |
| 99 | + if autoBalancer!.borrowScheduledTransaction(id: txnID)?.status() == FlowTransactionScheduler.Status.Scheduled { |
| 100 | + return true |
| 101 | + } |
| 102 | + } |
| 103 | + return false |
| 104 | + } |
| 105 | + |
| 106 | + /// Checks if an AutoBalancer is overdue for execution. |
| 107 | + /// A yield vault is considered overdue if: |
| 108 | + /// - It has a recurring config |
| 109 | + /// - The next expected execution time has passed |
| 110 | + /// - It has no active schedule |
| 111 | + /// |
| 112 | + /// @param id: The yield vault/AutoBalancer ID |
| 113 | + /// @return Bool: true if yield vault is overdue and stuck, false otherwise |
| 114 | + /// |
| 115 | + access(all) fun isStuckYieldVault(id: UInt64): Bool { |
| 116 | + let autoBalancer = self.borrowAutoBalancer(id: id) |
| 117 | + if autoBalancer == nil { |
| 118 | + return false |
| 119 | + } |
| 120 | + |
| 121 | + // Check if yield vault has recurring config (should be executing periodically) |
| 122 | + let config = autoBalancer!.getRecurringConfig() |
| 123 | + if config == nil { |
| 124 | + return false // Not configured for recurring, can't be "stuck" |
| 125 | + } |
| 126 | + |
| 127 | + // Check if there's an active schedule |
| 128 | + if self.hasActiveSchedule(id: id) { |
| 129 | + return false // Has active schedule, not stuck |
| 130 | + } |
| 131 | + |
| 132 | + // Check if yield vault is overdue |
| 133 | + let nextExpected = autoBalancer!.calculateNextExecutionTimestampAsConfigured() |
| 134 | + if nextExpected == nil { |
| 135 | + return true // Can't calculate next time, likely stuck |
| 136 | + } |
| 137 | + |
| 138 | + // If next expected time has passed and no active schedule, yield vault is stuck |
| 139 | + return nextExpected! < getCurrentBlock().timestamp |
| 140 | + } |
| 141 | + |
| 142 | + /* --- INTERNAL METHODS --- */ |
| 143 | + |
| 144 | + /// Configures a new AutoBalancer in storage, configures its public Capability, and sets its inner authorized |
| 145 | + /// Capability. If an AutoBalancer is stored with an associated UniqueID value, the operation reverts. |
| 146 | + /// |
| 147 | + /// @param oracle: The oracle used to query deposited & withdrawn value and to determine if a rebalance should execute |
| 148 | + /// @param vaultType: The type of Vault wrapped by the AutoBalancer |
| 149 | + /// @param lowerThreshold: The percentage below base value at which a rebalance pulls from rebalanceSource |
| 150 | + /// @param upperThreshold: The percentage above base value at which a rebalance pushes to rebalanceSink |
| 151 | + /// @param rebalanceSink: An optional DeFiActions Sink to which excess value is directed when rebalancing |
| 152 | + /// @param rebalanceSource: An optional DeFiActions Source from which value is withdrawn when rebalancing |
| 153 | + /// @param recurringConfig: Optional configuration for automatic recurring rebalancing via FlowTransactionScheduler |
| 154 | + /// @param uniqueID: The DeFiActions UniqueIdentifier used for identifying this AutoBalancer |
| 155 | + /// |
| 156 | + access(account) fun _initNewAutoBalancer( |
| 157 | + oracle: {DeFiActions.PriceOracle}, |
| 158 | + vaultType: Type, |
| 159 | + lowerThreshold: UFix64, |
| 160 | + upperThreshold: UFix64, |
| 161 | + rebalanceSink: {DeFiActions.Sink}?, |
| 162 | + rebalanceSource: {DeFiActions.Source}?, |
| 163 | + recurringConfig: AutoBalancers.AutoBalancerRecurringConfig?, |
| 164 | + uniqueID: DeFiActions.UniqueIdentifier |
| 165 | + ): auth(AutoBalancers.Auto, AutoBalancers.Set, AutoBalancers.Get, AutoBalancers.Schedule, FungibleToken.Withdraw) &AutoBalancers.AutoBalancer { |
| 166 | + |
| 167 | + // derive paths & prevent collision |
| 168 | + let storagePath = self.deriveAutoBalancerPath(id: uniqueID.id, storage: true) as! StoragePath |
| 169 | + let publicPath = self.deriveAutoBalancerPath(id: uniqueID.id, storage: false) as! PublicPath |
| 170 | + var storedType = self.account.storage.type(at: storagePath) |
| 171 | + var publishedCap = self.account.capabilities.exists(publicPath) |
| 172 | + assert(storedType == nil, |
| 173 | + message: "Storage collision when creating AutoBalancer for UniqueIdentifier.id \(uniqueID.id) at path \(storagePath)") |
| 174 | + assert(!publishedCap, |
| 175 | + message: "Published Capability collision found when publishing AutoBalancer for UniqueIdentifier.id \(uniqueID.id) at path \(publicPath)") |
| 176 | + |
| 177 | + let registryReportCallbackCapabilityStoragePath = |
| 178 | + StoragePath(identifier: "FlowYieldVaultsRegistryReportCallbackCapability")! |
| 179 | + if self.account.storage.type(at: registryReportCallbackCapabilityStoragePath) == nil { |
| 180 | + let sharedReportCap = self.account.capabilities.storage.issue<&{AutoBalancers.AutoBalancerExecutionCallback}>( |
| 181 | + self.registryReportCallbackStoragePath |
| 182 | + ) |
| 183 | + self.account.storage.save(sharedReportCap, to: registryReportCallbackCapabilityStoragePath) |
| 184 | + } |
| 185 | + let reportCap = self.account.storage.copy<Capability<&{AutoBalancers.AutoBalancerExecutionCallback}>>( |
| 186 | + from: registryReportCallbackCapabilityStoragePath |
| 187 | + ) ?? panic( |
| 188 | + "Missing shared registry report callback capability at \(registryReportCallbackCapabilityStoragePath)" |
| 189 | + ) |
| 190 | + |
| 191 | + // create & save AutoBalancer with optional recurring config |
| 192 | + let autoBalancer <- AutoBalancers.createAutoBalancer( |
| 193 | + oracle: oracle, |
| 194 | + vaultType: vaultType, |
| 195 | + lowerThreshold: lowerThreshold, |
| 196 | + upperThreshold: upperThreshold, |
| 197 | + rebalanceSink: rebalanceSink, |
| 198 | + rebalanceSource: rebalanceSource, |
| 199 | + recurringConfig: recurringConfig, |
| 200 | + uniqueID: uniqueID |
| 201 | + ) |
| 202 | + autoBalancer.setExecutionCallback(reportCap) |
| 203 | + self.account.storage.save(<-autoBalancer, to: storagePath) |
| 204 | + let autoBalancerRef = self._borrowAutoBalancer(uniqueID.id) |
| 205 | + |
| 206 | + // issue & publish public capability |
| 207 | + let publicCap = self.account.capabilities.storage.issue<&AutoBalancers.AutoBalancer>(storagePath) |
| 208 | + self.account.capabilities.publish(publicCap, at: publicPath) |
| 209 | + |
| 210 | + // issue private capability & set within AutoBalancer |
| 211 | + let authorizedCap = self.account.capabilities.storage.issue<auth(FungibleToken.Withdraw, FlowTransactionScheduler.Execute) &AutoBalancers.AutoBalancer>(storagePath) |
| 212 | + autoBalancerRef.setSelfCapability(authorizedCap) |
| 213 | + |
| 214 | + // ensure proper configuration before closing |
| 215 | + storedType = self.account.storage.type(at: storagePath) |
| 216 | + publishedCap = self.account.capabilities.exists(publicPath) |
| 217 | + assert(storedType == Type<@AutoBalancers.AutoBalancer>(), |
| 218 | + message: "Error when configuring AutoBalancer for UniqueIdentifier.id \(uniqueID.id) at path \(storagePath)") |
| 219 | + assert(publishedCap, |
| 220 | + message: "Error when publishing AutoBalancer Capability for UniqueIdentifier.id \(uniqueID.id) at path \(publicPath)") |
| 221 | + |
| 222 | + // Issue handler capability for the AutoBalancer (for FlowTransactionScheduler execution) |
| 223 | + let handlerCap = self.account.capabilities.storage |
| 224 | + .issue<auth(FlowTransactionScheduler.Execute) &{FlowTransactionScheduler.TransactionHandler}>(storagePath) |
| 225 | + |
| 226 | + // Issue schedule capability for the AutoBalancer (for Supervisor to call scheduleNextRebalance directly) |
| 227 | + let scheduleCap = self.account.capabilities.storage |
| 228 | + .issue<auth(AutoBalancers.Schedule) &AutoBalancers.AutoBalancer>(storagePath) |
| 229 | + |
| 230 | + // Register yield vault in registry for global mapping of live yield vault IDs |
| 231 | + FlowYieldVaultsSchedulerRegistryV1.register(yieldVaultID: uniqueID.id, handlerCap: handlerCap, scheduleCap: scheduleCap) |
| 232 | + |
| 233 | + // Start the native AutoBalancer self-scheduling chain if recurringConfig was provided |
| 234 | + // This schedules the first rebalance; subsequent ones are scheduled automatically |
| 235 | + // by the AutoBalancer after each execution (via recurringConfig) |
| 236 | + if recurringConfig != nil { |
| 237 | + let scheduleError = autoBalancerRef.scheduleNextRebalance(whileExecuting: nil) |
| 238 | + if scheduleError != nil { |
| 239 | + panic("Failed to schedule first rebalance for AutoBalancer \(uniqueID.id): ".concat(scheduleError!)) |
| 240 | + } |
| 241 | + } |
| 242 | + |
| 243 | + return autoBalancerRef |
| 244 | + } |
| 245 | + |
| 246 | + /// Returns an authorized reference on the AutoBalancer with the associated UniqueIdentifier.id. If none is found, |
| 247 | + /// the operation reverts. |
| 248 | + access(account) |
| 249 | + fun _borrowAutoBalancer(_ id: UInt64): auth(AutoBalancers.Auto, AutoBalancers.Set, AutoBalancers.Get, AutoBalancers.Schedule, FungibleToken.Withdraw) &AutoBalancers.AutoBalancer { |
| 250 | + let storagePath = self.deriveAutoBalancerPath(id: id, storage: true) as! StoragePath |
| 251 | + return self.account.storage.borrow<auth(AutoBalancers.Auto, AutoBalancers.Set, AutoBalancers.Get, AutoBalancers.Schedule, FungibleToken.Withdraw) &AutoBalancers.AutoBalancer>( |
| 252 | + from: storagePath |
| 253 | + ) ?? panic("Could not borrow reference to AutoBalancer with UniqueIdentifier.id \(id) from StoragePath \(storagePath)") |
| 254 | + } |
| 255 | + |
| 256 | + /// Called by strategies defined in the FlowYieldVaults account which leverage account-hosted AutoBalancers when a |
| 257 | + /// Strategy is burned |
| 258 | + access(account) fun _cleanupAutoBalancer(id: UInt64) { |
| 259 | + // Unregister from registry (removes from global yield vault mapping) |
| 260 | + FlowYieldVaultsSchedulerRegistryV1.unregister(yieldVaultID: id) |
| 261 | + |
| 262 | + let storagePath = self.deriveAutoBalancerPath(id: id, storage: true) as! StoragePath |
| 263 | + let publicPath = self.deriveAutoBalancerPath(id: id, storage: false) as! PublicPath |
| 264 | + // unpublish the public AutoBalancer Capability |
| 265 | + let _ = self.account.capabilities.unpublish(publicPath) |
| 266 | + |
| 267 | + // Collect controller IDs first (can't modify during iteration) |
| 268 | + var controllersToDelete: [UInt64] = [] |
| 269 | + self.account.capabilities.storage.forEachController(forPath: storagePath, fun(_ controller: &StorageCapabilityController): Bool { |
| 270 | + controllersToDelete.append(controller.capabilityID) |
| 271 | + return true |
| 272 | + }) |
| 273 | + // Delete controllers after iteration |
| 274 | + for controllerID in controllersToDelete { |
| 275 | + if let controller = self.account.capabilities.storage.getController(byCapabilityID: controllerID) { |
| 276 | + controller.delete() |
| 277 | + } |
| 278 | + } |
| 279 | + |
| 280 | + // load & burn the AutoBalancer (this also handles any pending scheduled transactions via burnCallback) |
| 281 | + let autoBalancer <-self.account.storage.load<@AutoBalancers.AutoBalancer>(from: storagePath) |
| 282 | + Burner.burn(<-autoBalancer) |
| 283 | + } |
| 284 | + |
| 285 | + init() { |
| 286 | + self.pathPrefix = "FlowYieldVaultsAutoBalancer_" |
| 287 | + self.registryReportCallbackStoragePath = StoragePath(identifier: "FlowYieldVaultsRegistryReportCallback")! |
| 288 | + |
| 289 | + // Ensure shared execution callback exists (reports this account's executions to Registry) |
| 290 | + if self.account.storage.type(at: self.registryReportCallbackStoragePath) == nil { |
| 291 | + self.account.storage.save(<-create RegistryReportCallback(), to: self.registryReportCallbackStoragePath) |
| 292 | + } |
| 293 | + } |
| 294 | +} |
0 commit comments