Skip to content
Merged
Show file tree
Hide file tree
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 Jan 26, 2026
8675a81
Add automatic rebalancing contracts
holyfuchs Jan 30, 2026
e202507
add more tests for auto rebalancing, small fixes and cleanup
holyfuchs Feb 2, 2026
1a0a59f
allow public calls to fixReschedule
holyfuchs Feb 2, 2026
11769e4
add supervisor for rebalancing
holyfuchs Feb 3, 2026
220271c
Apply suggestions from code review
holyfuchs Feb 6, 2026
c8802a7
address review comments
holyfuchs Feb 6, 2026
d5da5a5
add admin functions to enable,disable rebalancing for specific reabal…
holyfuchs Feb 7, 2026
6970361
Merge branch 'main' into holyfuchs/scheduled-rebalance
holyfuchs Feb 9, 2026
31b6726
Revert "add admin functions to enable,disable rebalancing for specifi…
holyfuchs Feb 9, 2026
85683ba
Merge branch 'main' into holyfuchs/scheduled-rebalance
holyfuchs Feb 9, 2026
6566beb
address PR comments
holyfuchs Feb 10, 2026
700ba93
replace FlowTransactionScheduler.estimate with cheaper alternative
holyfuchs Feb 10, 2026
9ac561b
address PR comments
holyfuchs Feb 10, 2026
26e80d4
Merge branch 'main' into holyfuchs/scheduled-rebalance
nialexsan Feb 10, 2026
0eb479c
improve documentation
holyfuchs Feb 11, 2026
ae8812c
Merge branch 'main' into holyfuchs/scheduled-rebalance
holyfuchs Feb 11, 2026
9a296db
rename txn to tx & simplify scheduleNextRebalance() errors
holyfuchs Feb 12, 2026
50ed5ec
Merge branch 'main' into holyfuchs/scheduled-rebalance
holyfuchs Feb 12, 2026
e64034a
panic on invalid fees balance
holyfuchs Feb 13, 2026
7d01ea8
small cleanup
holyfuchs Feb 13, 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
103 changes: 103 additions & 0 deletions RebalanceArchitecture.md
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
```
170 changes: 170 additions & 0 deletions cadence/contracts/FlowALPRebalancerPaidv1.cdc
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)
Comment thread
holyfuchs marked this conversation as resolved.
self.setSelfCapability(uuid: uuid).fixReschedule()
emit CreatedRebalancerPaid(uuid: uuid)
return <- create RebalancerPaid(rebalancerUUID: uuid)
Comment thread
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(
Comment thread
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(
Comment thread
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)
}
}
Loading
Loading