-
Notifications
You must be signed in to change notification settings - Fork 2
Add automatic rebalancing contracts #131
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from 17 commits
Commits
Show all changes
21 commits
Select commit
Hold shift + click to select a range
e741071
Bump FlowActions commit
Kay-Zee 8675a81
Add automatic rebalancing contracts
holyfuchs e202507
add more tests for auto rebalancing, small fixes and cleanup
holyfuchs 1a0a59f
allow public calls to fixReschedule
holyfuchs 11769e4
add supervisor for rebalancing
holyfuchs 220271c
Apply suggestions from code review
holyfuchs c8802a7
address review comments
holyfuchs d5da5a5
add admin functions to enable,disable rebalancing for specific reabal…
holyfuchs 6970361
Merge branch 'main' into holyfuchs/scheduled-rebalance
holyfuchs 31b6726
Revert "add admin functions to enable,disable rebalancing for specifi…
holyfuchs 85683ba
Merge branch 'main' into holyfuchs/scheduled-rebalance
holyfuchs 6566beb
address PR comments
holyfuchs 700ba93
replace FlowTransactionScheduler.estimate with cheaper alternative
holyfuchs 9ac561b
address PR comments
holyfuchs 26e80d4
Merge branch 'main' into holyfuchs/scheduled-rebalance
nialexsan 0eb479c
improve documentation
holyfuchs ae8812c
Merge branch 'main' into holyfuchs/scheduled-rebalance
holyfuchs 9a296db
rename txn to tx & simplify scheduleNextRebalance() errors
holyfuchs 50ed5ec
Merge branch 'main' into holyfuchs/scheduled-rebalance
holyfuchs e64034a
panic on invalid fees balance
holyfuchs 7d01ea8
small cleanup
holyfuchs File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,103 @@ | ||
| ## Updated Rebalance Architecture | ||
|
|
||
| This system **rebalances Flow Credit Market (FCM) positions on a schedule**: at a configurable interval, a rebalancer triggers the position’s `rebalance` function. **FCM** holds positions and exposes `rebalance`. | ||
|
|
||
| A **Rebalancer** when invoked, calls `rebalance` on the position and tries to schedules the next run. | ||
|
|
||
| A **Supervisor** runs on its own schedule (cron) and calls `fixReschedule()` on each registered rebalancer so that transient scheduling failures (e.g. temporary lack of funds) don’t leave rebalancers stuck. | ||
|
|
||
| ### Key Principles | ||
|
|
||
| * **Isolation:** FCM, Rebalancer, and Supervisor are fully independent. | ||
| * **Least Privilege:** The Rebalancer can *only* trigger the `rebalance` function. | ||
| * **Resilience:** The `fixReschedule()` call is idempotent and permissionless, ensuring the system can recover without complex auth (see below). | ||
|
|
||
| ### Rebalancer config (RecurringConfig) | ||
|
|
||
| Each rebalancer is driven by a **RecurringConfig** that defines how and who pays for scheduled rebalances: | ||
|
|
||
| | Field | Purpose | | ||
| |-------|--------| | ||
| | **interval** | How often to run (seconds). | | ||
| | **priority** | Scheduler priority (not High). | | ||
| | **executionEffort** | Execution effort for fee estimation. | | ||
| | **estimationMargin** | Multiplier on estimated fees (feePaid = estimate × margin). | | ||
| | **forceRebalance** | Whether to force rebalance when invoked. (bool provided to the rebalance function) | | ||
| | **txnFunder** | **Who pays for rebalance transactions.** A Sink/Source (FLOW) used to pay the FlowTransactionScheduler. The rebalancer withdraws from it when scheduling the next run and refunds on cancel. | | ||
|
|
||
| The rebalancer uses this config to: (1) call `rebalance(force)` on the position when the scheduler fires, (2) compute the next run time from `interval`, (3) withdraw FLOW from **txnFunder** to pay the scheduler for the next scheduled transaction, and (4) on config change or cancel, refund unused fees back to **txnFunder**. So **txnFunder is the account that actually pays** for each scheduled rebalance. | ||
|
|
||
| ### Rebalancer variants | ||
|
|
||
| There are two rebalancer types; they behave the same for triggering rebalances; the difference is **who supplies the config (and thus the txnFunder)** and **who can change it**. | ||
|
|
||
| | | **Standard Rebalancer** | **Paid Rebalancer** | | ||
| |---|---|---| | ||
| | **Who pays** | User pays (user’s txnFunder) | Admin pays (admin’s txnFunder in config) | | ||
| | **Where rebalancer lives** | In the user’s account | In the Paid contract’s account | | ||
| | **Config ownership** | User: they set RecurringConfig and can call `setRecurringConfig` | Admin/contract: `defaultRecurringConfig` for new ones; admin can `updateRecurringConfig(uuid, …)` per rebalancer | | ||
| | **User’s control** | Full: config, fixReschedule, withdraw/destroy | Only: fixReschedule by UUID, or delete their RebalancerPaid (stops and removes the rebalancer) | | ||
| | **Use case** | User wants full autonomy and to pay their own fees | Admin retains autonomy and pays fees for users (us only) | | ||
|
|
||
| **Note:** The Supervisor and the Paid Rebalancer are only intended for use by us; the Standard Rebalancer is for users who self-custody. The bundled `FlowALPSupervisorV1` only tracks **paid** rebalancers (`addPaidRebalancer` / `removePaidRebalancer`). For standard rebalancers, users can call `fixReschedule()` themselves when needed. | ||
|
|
||
| ### Why calls `fixReschedule()` are necessary | ||
|
|
||
| After each rebalance run, the rebalancer calls `scheduleNextRebalance()` to book the next run with the FlowTransactionScheduler. That call can **fail** for transient reasons (e.g. `INSUFFICIENT_FEES_AVAILABLE`, scheduler busy, or the txnFunder reverting). When it fails, the rebalancer emits `FailedRecurringSchedule` and does **not** schedule the next execution — so the rebalancer is left with **no upcoming scheduled transaction** and would never run again unless something reschedules it. | ||
|
|
||
| `fixReschedule()` is **idempotent**: if there is no scheduled transaction, it tries to schedule the next one (and may emit `FailedRecurringSchedule` again if it still fails); if there is already a scheduled transaction, it does nothing. | ||
|
|
||
| The supervisor runs on a fixed schedule (cron) and, for each registered rebalancer, calls `fixReschedule()`. So even when a rebalancer failed to schedule its next run (e.g. temporary lack of funds), a later supervisor tick can **recover** it without the user having to do anything. The supervisor therefore provides **resilience against transient scheduling failures** and keeps rebalancers from getting stuck permanently. | ||
|
|
||
| ### Creating a position (paid rebalancer) | ||
|
|
||
| User creates a position, then creates a **paid** rebalancer (which lives in the contract) and registers it with the supervisor so the supervisor can call `fixReschedule()` on it. | ||
|
|
||
| ```mermaid | ||
| sequenceDiagram | ||
| actor admin | ||
| actor User | ||
| participant FCM | ||
| participant Paid as Paid Rebalancer Contract | ||
| participant Supervisor | ||
| Note over admin,Paid: One-time: admin sets defaultRecurringConfig (incl. txnFunder) | ||
| admin->>Paid: updateDefaultRecurringConfig(config) | ||
| User->>FCM: createPosition() | ||
| User->>Paid: createPaidRebalancer(positionRebalanceCapability) | ||
| Paid-->>User: RebalancerPaid(uuid) | ||
| User->>User: save RebalancerPaid | ||
| User->>Supervisor: addPaidRebalancer(uuid) | ||
| ``` | ||
|
|
||
| ### Stopping the rebalance | ||
|
|
||
| ```mermaid | ||
| sequenceDiagram | ||
| participant User | ||
| participant Paid as Paid Rebalancer | ||
| participant Supervisor | ||
| Note over User,Supervisor: Stop paid rebalancer | ||
| User->>Supervisor: removePaidRebalancer(uuid) | ||
| User->>Paid: delete RebalancerPaid (or admin: removePaidRebalancer(uuid)) | ||
| Paid->>Paid: cancelAllScheduledTransactions(), destroy Rebalancer | ||
| ``` | ||
|
|
||
| ### While running | ||
|
|
||
| ```mermaid | ||
| sequenceDiagram | ||
| participant AB1 as AutoRebalancer1 | ||
| participant FCM | ||
| participant AB2 as AutoRebalancer2 | ||
| participant SUP as Supervisor | ||
| loop every x min | ||
| AB1->>FCM: rebalance() | ||
| end | ||
| loop every y min | ||
| AB2->>FCM: rebalance() | ||
| end | ||
| loop every z min | ||
| SUP->>AB2: fixReschedule() | ||
| SUP->>AB1: fixReschedule() | ||
| end | ||
| ``` |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,170 @@ | ||
| import "FlowALPv1" | ||
| import "FlowALPRebalancerv1" | ||
| import "FlowTransactionScheduler" | ||
|
|
||
| // FlowALPRebalancerPaidv1 — Managed rebalancer service for Flow ALP positions. | ||
| // | ||
| // Intended for use by the protocol operators only. This contract hosts scheduled rebalancers | ||
| // on behalf of users. Instead of users storing and configuring Rebalancer resources themselves, | ||
| // they call createPaidRebalancer with a position rebalance capability and receive a lightweight | ||
| // RebalancerPaid resource. The contract stores the underlying Rebalancer, wires it to the | ||
| // FlowTransactionScheduler, and applies defaultRecurringConfig (interval, priority, txnFunder, etc.). | ||
| // The admin's txnFunder in that config is used to pay for rebalance transactions. Users can | ||
| // fixReschedule (via their RebalancerPaid) or delete RebalancerPaid to stop. Admins control the | ||
| // default config and can update or remove individual paid rebalancers. See RebalanceArchitecture.md. | ||
| access(all) contract FlowALPRebalancerPaidv1 { | ||
|
|
||
| access(all) event CreatedRebalancerPaid(uuid: UInt64) | ||
| access(all) event RemovedRebalancerPaid(uuid: UInt64) | ||
| access(all) event UpdatedDefaultRecurringConfig( | ||
| interval: UInt64, | ||
| priority: UInt8, | ||
| executionEffort: UInt64, | ||
| estimationMargin: UFix64, | ||
| forceRebalance: Bool, | ||
| ) | ||
|
|
||
| /// Default RecurringConfig for all newly created paid rebalancers. Must be set by Admin before | ||
| /// createPaidRebalancer is used. Includes txnFunder, which pays for scheduled rebalance transactions. | ||
| access(all) var defaultRecurringConfig: FlowALPRebalancerv1.RecurringConfig? | ||
| access(all) var adminStoragePath: StoragePath | ||
|
|
||
| /// Create a paid rebalancer for the given position. Uses defaultRecurringConfig (must be set). | ||
| /// Returns a RebalancerPaid resource; the underlying Rebalancer is stored in this contract and | ||
| /// the first run is scheduled. Caller should register the returned uuid with a Supervisor. | ||
| access(all) fun createPaidRebalancer( | ||
| positionRebalanceCapability: Capability<auth(FlowALPv1.ERebalance) &FlowALPv1.Position>, | ||
| ): @RebalancerPaid { | ||
| assert(positionRebalanceCapability.check(), message: "Invalid position rebalance capability") | ||
| let rebalancer <- FlowALPRebalancerv1.createRebalancer( | ||
| recurringConfig: self.defaultRecurringConfig!, | ||
| positionRebalanceCapability: positionRebalanceCapability | ||
| ) | ||
| let uuid = rebalancer.uuid | ||
| self.storeRebalancer(rebalancer: <-rebalancer) | ||
| self.setSelfCapability(uuid: uuid).fixReschedule() | ||
| emit CreatedRebalancerPaid(uuid: uuid) | ||
| return <- create RebalancerPaid(rebalancerUUID: uuid) | ||
|
holyfuchs marked this conversation as resolved.
|
||
| } | ||
|
|
||
| /// Admin resource: controls default config and per-rebalancer config; can remove paid rebalancers. | ||
| access(all) resource Admin { | ||
| /// Set the default RecurringConfig for all newly created paid rebalancers (interval, txnFunder, etc.). | ||
| access(all) fun updateDefaultRecurringConfig(recurringConfig: FlowALPRebalancerv1.RecurringConfig) { | ||
| FlowALPRebalancerPaidv1.defaultRecurringConfig = recurringConfig | ||
| emit UpdatedDefaultRecurringConfig( | ||
| interval: recurringConfig.interval, | ||
| priority: recurringConfig.priority.rawValue, | ||
| executionEffort: recurringConfig.executionEffort, | ||
| estimationMargin: recurringConfig.estimationMargin, | ||
| forceRebalance: recurringConfig.forceRebalance, | ||
| ) | ||
| } | ||
|
|
||
| /// Borrow a paid rebalancer with Configure and ERebalance auth (e.g. for setRecurringConfig or rebalance). | ||
| access(all) fun borrowAuthorizedRebalancer( | ||
| uuid: UInt64, | ||
| ): auth(FlowALPv1.ERebalance, FlowALPRebalancerv1.Rebalancer.Configure) &FlowALPRebalancerv1.Rebalancer? { | ||
| return FlowALPRebalancerPaidv1.borrowRebalancer(uuid: uuid) | ||
| } | ||
|
|
||
| /// Update the RecurringConfig for a specific paid rebalancer (interval, txnFunder, etc.). | ||
| access(all) fun updateRecurringConfig( | ||
| uuid: UInt64, | ||
| recurringConfig: FlowALPRebalancerv1.RecurringConfig) | ||
| { | ||
| let rebalancer = FlowALPRebalancerPaidv1.borrowRebalancer(uuid: uuid)! | ||
| rebalancer.setRecurringConfig(recurringConfig) | ||
| } | ||
|
|
||
| /// Remove a paid rebalancer: cancel scheduled transactions (refund to txnFunder) and destroy it. | ||
| access(account) fun removePaidRebalancer(uuid: UInt64) { | ||
| FlowALPRebalancerPaidv1.removePaidRebalancer(uuid: uuid) | ||
| emit RemovedRebalancerPaid(uuid: uuid) | ||
| } | ||
| } | ||
|
|
||
| access(all) entitlement Delete | ||
|
|
||
| /// User's handle to a paid rebalancer. Allows fixReschedule (recover if scheduling failed) or | ||
| /// delete (stop and remove the rebalancer; caller should also remove from Supervisor). | ||
| access(all) resource RebalancerPaid { | ||
| // the UUID of the rebalancer this resource is associated with | ||
| access(all) var rebalancerUUID : UInt64 | ||
|
|
||
| init(rebalancerUUID: UInt64) { | ||
| self.rebalancerUUID = rebalancerUUID | ||
| } | ||
|
|
||
| /// Stop and remove the paid rebalancer; scheduled transactions are cancelled and fees refunded to the admin txnFunder. | ||
| access(Delete) fun delete() { | ||
| FlowALPRebalancerPaidv1.removePaidRebalancer(uuid: self.rebalancerUUID) | ||
| } | ||
|
|
||
| /// Idempotent: if no next run is scheduled, try to schedule it (e.g. after a transient failure). | ||
| access(all) fun fixReschedule() { | ||
| FlowALPRebalancerPaidv1.fixReschedule(uuid: self.rebalancerUUID) | ||
| } | ||
| } | ||
|
|
||
| /// Idempotent: for the given paid rebalancer, if there is no scheduled transaction, schedule the next run. | ||
| /// Callable by anyone (e.g. the Supervisor or the RebalancerPaid owner). | ||
| access(all) fun fixReschedule( | ||
|
nialexsan marked this conversation as resolved.
|
||
| uuid: UInt64, | ||
| ) { | ||
| let rebalancer = FlowALPRebalancerPaidv1.borrowRebalancer(uuid: uuid)! | ||
| rebalancer.fixReschedule() | ||
| } | ||
|
|
||
| /// Storage path where a user would store their RebalancerPaid for the given uuid (convention for discovery). | ||
| access(all) view fun getPaidRebalancerPath( | ||
| uuid: UInt64, | ||
| ): StoragePath { | ||
| return StoragePath(identifier: "FlowALP.RebalancerPaidv1_\(self.account.address)_\(uuid)")! | ||
| } | ||
|
|
||
| access(self) fun borrowRebalancer( | ||
| uuid: UInt64, | ||
| ): auth(FlowALPv1.ERebalance, FlowALPRebalancerv1.Rebalancer.Configure) &FlowALPRebalancerv1.Rebalancer? { | ||
| return self.account.storage.borrow<auth(FlowALPv1.ERebalance, FlowALPRebalancerv1.Rebalancer.Configure) &FlowALPRebalancerv1.Rebalancer>(from: self.getPath(uuid: uuid)) | ||
| } | ||
|
|
||
| access(self) fun removePaidRebalancer(uuid: UInt64) { | ||
| let rebalancer <- self.account.storage.load<@FlowALPRebalancerv1.Rebalancer>(from: self.getPath(uuid: uuid)) | ||
| rebalancer?.cancelAllScheduledTransactions() | ||
| destroy <- rebalancer | ||
| } | ||
|
|
||
| access(self) fun storeRebalancer( | ||
| rebalancer: @FlowALPRebalancerv1.Rebalancer, | ||
| ) { | ||
| let path = self.getPath(uuid: rebalancer.uuid) | ||
| self.account.storage.save(<-rebalancer, to: path) | ||
| } | ||
|
|
||
| /// Issue a capability to the stored Rebalancer and set it on the Rebalancer so it can pass itself to the scheduler as the execute callback. | ||
| access(self) fun setSelfCapability( | ||
|
holyfuchs marked this conversation as resolved.
|
||
| uuid: UInt64, | ||
| ) : auth(FlowALPv1.ERebalance, FlowALPRebalancerv1.Rebalancer.Configure) &FlowALPRebalancerv1.Rebalancer { | ||
| let selfCap = self.account.capabilities.storage.issue<auth(FlowTransactionScheduler.Execute) &{FlowTransactionScheduler.TransactionHandler}>(self.getPath(uuid: uuid)) | ||
| // The Rebalancer is stored in the contract storage (storeRebalancer), | ||
| // it needs a capability pointing to itself to pass to the scheduler. | ||
| // We issue this capability here and set it on the Rebalancer, so that when | ||
| // fixReschedule is called, the Rebalancer can pass it to the transaction scheduler | ||
| // as a callback for executing scheduled rebalances. | ||
| let rebalancer = self.borrowRebalancer(uuid: uuid)! | ||
| rebalancer.setSelfCapability(selfCap) | ||
| return rebalancer | ||
| } | ||
|
|
||
| access(self) view fun getPath(uuid: UInt64): StoragePath { | ||
| return StoragePath(identifier: "FlowALP.RebalancerPaidv1\(uuid)")! | ||
| } | ||
|
|
||
| init() { | ||
| self.adminStoragePath = StoragePath(identifier: "FlowALP.RebalancerPaidv1.Admin")! | ||
| self.defaultRecurringConfig = nil | ||
| let admin <- create Admin() | ||
| self.account.storage.save(<-admin, to: self.adminStoragePath) | ||
| } | ||
| } | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.