Skip to content

Commit c488dfb

Browse files
authored
Merge pull request #76 from onflow/scheduled-rebalancing
Global supervisor rebalancing + auto-register + strict proofs + E2E tests
2 parents aedb318 + 0f1e02f commit c488dfb

52 files changed

Lines changed: 5465 additions & 36 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.cursor/rules/standards.mdc

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,4 +3,8 @@ description:
33
globs:
44
alwaysApply: true
55
---
6-
Do not use emojis
6+
- Do not use emojis
7+
- All documentation markdown files (.md) should be placed in the `docs/` folder, not in the repository root
8+
- Keep README.md in the root as the only exception
9+
- When writing tests, set expectations based on the architecture and requirements, NOT to make tests pass. Never reduce expected values to match actual behavior - instead fix the implementation to meet expectations.
10+
- Always run all tests locally before pushing changes to verify they pass

.cursorignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
*.pkey
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
name: Scheduled Rebalance Tests
2+
3+
on:
4+
push:
5+
branches:
6+
- main
7+
- scheduled-rebalancing
8+
pull_request:
9+
branches:
10+
- main
11+
12+
jobs:
13+
scheduled-rebalance-tests:
14+
name: FlowVaults Scheduled Rebalancing Tests
15+
runs-on: ubuntu-latest
16+
steps:
17+
- uses: actions/checkout@v4
18+
with:
19+
token: ${{ secrets.GH_PAT }}
20+
submodules: recursive
21+
- name: Set up Go
22+
uses: actions/setup-go@v3
23+
with:
24+
go-version: "1.23.x"
25+
- uses: actions/cache@v4
26+
with:
27+
path: ~/go/pkg/mod
28+
key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }}
29+
restore-keys: |
30+
${{ runner.os }}-go-
31+
- name: Install Flow CLI
32+
run: sh -ci "$(curl -fsSL https://raw.githubusercontent.com/onflow/flow-cli/master/install.sh)"
33+
- name: Flow CLI Version
34+
run: flow version
35+
- name: Update PATH
36+
run: echo "/root/.local/bin" >> $GITHUB_PATH
37+
- name: Install dependencies
38+
run: flow deps install --skip-alias --skip-deployments
39+
- name: Run scheduled rebalancing tests
40+
run: |
41+
flow test \
42+
cadence/tests/scheduled_rebalance_integration_test.cdc \
43+
cadence/tests/scheduled_rebalance_scenario_test.cdc \
44+
cadence/tests/tide_lifecycle_test.cdc \
45+
cadence/tests/atomic_registration_gc_test.cdc \
46+
cadence/tests/scheduled_supervisor_test.cdc \
47+
cadence/tests/scheduler_edge_cases_test.cdc
48+
49+

.gitignore

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
# local
22
.DS_Store
3+
**/.DS_Store
34
.pr-drafts/
45
.vscode/
56

@@ -20,3 +21,6 @@ solidity/out/
2021
broadcast
2122
cache
2223
db
24+
25+
# logs
26+
run_logs/*.log

cadence/.DS_Store

-6 KB
Binary file not shown.

cadence/contracts/FlowVaults.cdc

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -231,7 +231,8 @@ access(all) contract FlowVaults {
231231
remainingBalance: self.getTideBalance()
232232
)
233233
let _strategy <- self.strategy <- nil
234-
Burner.burn(<-_strategy)
234+
// Force unwrap to ensure burnCallback is called on the Strategy
235+
Burner.burn(<-_strategy!)
235236
}
236237
/// TODO: FlowVaults specific views
237238
access(all) view fun getViews(): [Type] {
@@ -318,18 +319,24 @@ access(all) contract FlowVaults {
318319
access(all) view fun getNumberOfTides(): Int {
319320
return self.tides.length
320321
}
321-
/// Creates a new Tide executing the specified Strategy with the provided funds
322-
access(all) fun createTide(betaRef: auth(FlowVaultsClosedBeta.Beta) &FlowVaultsClosedBeta.BetaBadge, strategyType: Type, withVault: @{FungibleToken.Vault}) {
322+
/// Creates a new Tide executing the specified Strategy with the provided funds.
323+
/// Returns the newly created Tide ID.
324+
access(all) fun createTide(
325+
betaRef: auth(FlowVaultsClosedBeta.Beta) &FlowVaultsClosedBeta.BetaBadge,
326+
strategyType: Type,
327+
withVault: @{FungibleToken.Vault}
328+
): UInt64 {
323329
pre {
324330
FlowVaultsClosedBeta.validateBeta(self.owner?.address!, betaRef):
325331
"Invalid Beta Ref"
326332
}
327333
let balance = withVault.balance
328334
let type = withVault.getType()
329335
let tide <-create Tide(strategyType: strategyType, withVault: <-withVault)
336+
let newID = tide.uniqueID.id
330337

331338
emit CreatedTide(
332-
id: tide.uniqueID.id,
339+
id: newID,
333340
uuid: tide.uuid,
334341
strategyType: strategyType.identifier,
335342
tokenType: type.identifier,
@@ -338,6 +345,8 @@ access(all) contract FlowVaults {
338345
)
339346

340347
self.addTide(betaRef: betaRef, <-tide)
348+
349+
return newID
341350
}
342351
/// Adds an open Tide to this TideManager resource. This effectively transfers ownership of the newly added
343352
/// Tide to the owner of this TideManager
@@ -380,7 +389,7 @@ access(all) contract FlowVaults {
380389
FlowVaultsClosedBeta.validateBeta(self.owner?.address!, betaRef):
381390
"Invalid Beta Ref"
382391
}
383-
return <- self._withdrawTide(id: id)!
392+
return <- self._withdrawTide(id: id)
384393
}
385394
/// Withdraws funds from the specified Tide in the given amount. The resulting Vault Type will be whatever
386395
/// denomination is supported by the Tide, so callers should examine the Tide to know the resulting Vault to
@@ -400,6 +409,7 @@ access(all) contract FlowVaults {
400409
self.tides[id] != nil:
401410
"No Tide with ID \(id) found"
402411
}
412+
403413
let tide <- self._withdrawTide(id: id)
404414
let res <- tide.withdraw(amount: tide.getTideBalance())
405415
Burner.burn(<-tide)

cadence/contracts/FlowVaultsAutoBalancers.cdc

Lines changed: 120 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ import "FungibleToken"
44
// DeFiActions
55
import "DeFiActions"
66
import "FlowTransactionScheduler"
7+
// Registry for global tide mapping
8+
import "FlowVaultsSchedulerRegistry"
79

810
/// FlowVaultsAutoBalancers
911
///
@@ -14,7 +16,13 @@ import "FlowTransactionScheduler"
1416
/// which identifies all DeFiActions components in the stack related to their composite Strategy.
1517
///
1618
/// When a Tide and necessarily the related Strategy is closed & burned, the related AutoBalancer and its Capabilities
17-
/// are destroyed and deleted
19+
/// are destroyed and deleted.
20+
///
21+
/// Scheduling approach:
22+
/// - AutoBalancers are configured with a recurringConfig at creation
23+
/// - After creation, scheduleNextRebalance(nil) starts the self-scheduling chain
24+
/// - The registry tracks all live tide IDs for global mapping
25+
/// - Cleanup unregisters from the registry
1826
///
1927
access(all) contract FlowVaultsAutoBalancers {
2028

@@ -36,19 +44,89 @@ access(all) contract FlowVaultsAutoBalancers {
3644
return self.account.capabilities.borrow<&DeFiActions.AutoBalancer>(publicPath)
3745
}
3846

47+
/// Checks if an AutoBalancer has at least one active (Scheduled) transaction.
48+
/// Used by Supervisor to detect stuck tides that need recovery.
49+
///
50+
/// @param id: The tide/AutoBalancer ID
51+
/// @return Bool: true if there's at least one Scheduled transaction, false otherwise
52+
///
53+
access(all) fun hasActiveSchedule(id: UInt64): Bool {
54+
let autoBalancer = self.borrowAutoBalancer(id: id)
55+
if autoBalancer == nil {
56+
return false
57+
}
58+
59+
let txnIDs = autoBalancer!.getScheduledTransactionIDs()
60+
for txnID in txnIDs {
61+
if let txnRef = autoBalancer!.borrowScheduledTransaction(id: txnID) {
62+
if txnRef.status() == FlowTransactionScheduler.Status.Scheduled {
63+
return true
64+
}
65+
}
66+
}
67+
return false
68+
}
69+
70+
/// Checks if an AutoBalancer is overdue for execution.
71+
/// A tide is considered overdue if:
72+
/// - It has a recurring config
73+
/// - The next expected execution time has passed
74+
/// - It has no active schedule
75+
///
76+
/// @param id: The tide/AutoBalancer ID
77+
/// @return Bool: true if tide is overdue and stuck, false otherwise
78+
///
79+
access(all) fun isStuckTide(id: UInt64): Bool {
80+
let autoBalancer = self.borrowAutoBalancer(id: id)
81+
if autoBalancer == nil {
82+
return false
83+
}
84+
85+
// Check if tide has recurring config (should be executing periodically)
86+
let config = autoBalancer!.getRecurringConfig()
87+
if config == nil {
88+
return false // Not configured for recurring, can't be "stuck"
89+
}
90+
91+
// Check if there's an active schedule
92+
if self.hasActiveSchedule(id: id) {
93+
return false // Has active schedule, not stuck
94+
}
95+
96+
// Check if tide is overdue
97+
let nextExpected = autoBalancer!.calculateNextExecutionTimestampAsConfigured()
98+
if nextExpected == nil {
99+
return true // Can't calculate next time, likely stuck
100+
}
101+
102+
// If next expected time has passed and no active schedule, tide is stuck
103+
return nextExpected! < getCurrentBlock().timestamp
104+
}
105+
39106
/* --- INTERNAL METHODS --- */
40107

41108
/// Configures a new AutoBalancer in storage, configures its public Capability, and sets its inner authorized
42109
/// Capability. If an AutoBalancer is stored with an associated UniqueID value, the operation reverts.
110+
///
111+
/// @param oracle: The oracle used to query deposited & withdrawn value and to determine if a rebalance should execute
112+
/// @param vaultType: The type of Vault wrapped by the AutoBalancer
113+
/// @param lowerThreshold: The percentage below base value at which a rebalance pulls from rebalanceSource
114+
/// @param upperThreshold: The percentage above base value at which a rebalance pushes to rebalanceSink
115+
/// @param rebalanceSink: An optional DeFiActions Sink to which excess value is directed when rebalancing
116+
/// @param rebalanceSource: An optional DeFiActions Source from which value is withdrawn when rebalancing
117+
/// @param recurringConfig: Optional configuration for automatic recurring rebalancing via FlowTransactionScheduler
118+
/// @param uniqueID: The DeFiActions UniqueIdentifier used for identifying this AutoBalancer
119+
///
43120
access(account) fun _initNewAutoBalancer(
44121
oracle: {DeFiActions.PriceOracle},
45122
vaultType: Type,
46123
lowerThreshold: UFix64,
47124
upperThreshold: UFix64,
48125
rebalanceSink: {DeFiActions.Sink}?,
49126
rebalanceSource: {DeFiActions.Source}?,
127+
recurringConfig: DeFiActions.AutoBalancerRecurringConfig?,
50128
uniqueID: DeFiActions.UniqueIdentifier
51-
): auth(DeFiActions.Auto, DeFiActions.Set, DeFiActions.Get, FungibleToken.Withdraw) &DeFiActions.AutoBalancer {
129+
): auth(DeFiActions.Auto, DeFiActions.Set, DeFiActions.Get, DeFiActions.Schedule, FungibleToken.Withdraw) &DeFiActions.AutoBalancer {
52130

53131
// derive paths & prevent collision
54132
let storagePath = self.deriveAutoBalancerPath(id: uniqueID.id, storage: true) as! StoragePath
@@ -60,15 +138,15 @@ access(all) contract FlowVaultsAutoBalancers {
60138
assert(!publishedCap,
61139
message: "Published Capability collision found when publishing AutoBalancer for UniqueIdentifier.id \(uniqueID.id) at path \(publicPath)")
62140

63-
// create & save AutoBalancer
141+
// create & save AutoBalancer with optional recurring config
64142
let autoBalancer <- DeFiActions.createAutoBalancer(
65143
oracle: oracle,
66144
vaultType: vaultType,
67145
lowerThreshold: lowerThreshold,
68146
upperThreshold: upperThreshold,
69147
rebalanceSink: rebalanceSink,
70148
rebalanceSource: rebalanceSource,
71-
recurringConfig: nil,
149+
recurringConfig: recurringConfig,
72150
uniqueID: uniqueID
73151
)
74152
self.account.storage.save(<-autoBalancer, to: storagePath)
@@ -89,32 +167,64 @@ access(all) contract FlowVaultsAutoBalancers {
89167
message: "Error when configuring AutoBalancer for UniqueIdentifier.id \(uniqueID.id) at path \(storagePath)")
90168
assert(publishedCap,
91169
message: "Error when publishing AutoBalancer Capability for UniqueIdentifier.id \(uniqueID.id) at path \(publicPath)")
170+
171+
// Issue handler capability for the AutoBalancer (for FlowTransactionScheduler execution)
172+
let handlerCap = self.account.capabilities.storage
173+
.issue<auth(FlowTransactionScheduler.Execute) &{FlowTransactionScheduler.TransactionHandler}>(storagePath)
174+
175+
// Issue schedule capability for the AutoBalancer (for Supervisor to call scheduleNextRebalance directly)
176+
let scheduleCap = self.account.capabilities.storage
177+
.issue<auth(DeFiActions.Schedule) &DeFiActions.AutoBalancer>(storagePath)
178+
179+
// Register tide in registry for global mapping of live tide IDs
180+
FlowVaultsSchedulerRegistry.register(tideID: uniqueID.id, handlerCap: handlerCap, scheduleCap: scheduleCap)
181+
182+
// Start the native AutoBalancer self-scheduling chain
183+
// This schedules the first rebalance; subsequent ones are scheduled automatically
184+
// by the AutoBalancer after each execution (via recurringConfig)
185+
let scheduleError = autoBalancerRef.scheduleNextRebalance(whileExecuting: nil)
186+
if scheduleError != nil {
187+
panic("Failed to schedule first rebalance for AutoBalancer \(uniqueID.id): ".concat(scheduleError!))
188+
}
189+
92190
return autoBalancerRef
93191
}
94192

95193
/// Returns an authorized reference on the AutoBalancer with the associated UniqueIdentifier.id. If none is found,
96194
/// the operation reverts.
97195
access(account)
98-
fun _borrowAutoBalancer(_ id: UInt64): auth(DeFiActions.Auto, DeFiActions.Set, DeFiActions.Get, FungibleToken.Withdraw) &DeFiActions.AutoBalancer {
196+
fun _borrowAutoBalancer(_ id: UInt64): auth(DeFiActions.Auto, DeFiActions.Set, DeFiActions.Get, DeFiActions.Schedule, FungibleToken.Withdraw) &DeFiActions.AutoBalancer {
99197
let storagePath = self.deriveAutoBalancerPath(id: id, storage: true) as! StoragePath
100-
return self.account.storage.borrow<auth(DeFiActions.Auto, DeFiActions.Set, DeFiActions.Get, FungibleToken.Withdraw) &DeFiActions.AutoBalancer>(
198+
return self.account.storage.borrow<auth(DeFiActions.Auto, DeFiActions.Set, DeFiActions.Get, DeFiActions.Schedule, FungibleToken.Withdraw) &DeFiActions.AutoBalancer>(
101199
from: storagePath
102200
) ?? panic("Could not borrow reference to AutoBalancer with UniqueIdentifier.id \(id) from StoragePath \(storagePath)")
103201
}
104202

105203
/// Called by strategies defined in the FlowVaults account which leverage account-hosted AutoBalancers when a
106204
/// Strategy is burned
107205
access(account) fun _cleanupAutoBalancer(id: UInt64) {
206+
// Unregister from registry (removes from global tide mapping)
207+
FlowVaultsSchedulerRegistry.unregister(tideID: id)
208+
108209
let storagePath = self.deriveAutoBalancerPath(id: id, storage: true) as! StoragePath
109210
let publicPath = self.deriveAutoBalancerPath(id: id, storage: false) as! PublicPath
110211
// unpublish the public AutoBalancer Capability
111-
self.account.capabilities.unpublish(publicPath)
112-
// delete any CapabilityControllers targetting the AutoBalancer
212+
let _ = self.account.capabilities.unpublish(publicPath)
213+
214+
// Collect controller IDs first (can't modify during iteration)
215+
var controllersToDelete: [UInt64] = []
113216
self.account.capabilities.storage.forEachController(forPath: storagePath, fun(_ controller: &StorageCapabilityController): Bool {
114-
controller.delete()
217+
controllersToDelete.append(controller.capabilityID)
115218
return true
116219
})
117-
// load & burn the AutoBalancer
220+
// Delete controllers after iteration
221+
for controllerID in controllersToDelete {
222+
if let controller = self.account.capabilities.storage.getController(byCapabilityID: controllerID) {
223+
controller.delete()
224+
}
225+
}
226+
227+
// load & burn the AutoBalancer (this also handles any pending scheduled transactions via burnCallback)
118228
let autoBalancer <-self.account.storage.load<@DeFiActions.AutoBalancer>(from: storagePath)
119229
Burner.burn(<-autoBalancer)
120230
}

0 commit comments

Comments
 (0)