From 3a845e82789f4a50316296016c3a8177fd952028 Mon Sep 17 00:00:00 2001 From: Patrick Fuchs Date: Fri, 27 Mar 2026 15:16:08 +0100 Subject: [PATCH 1/3] refactor: simplify FlowALPRebalancerPaidv1 --- RebalanceArchitecture.md | 97 +++-- cadence/contracts/FlowALPRebalancerPaidv1.cdc | 348 +++++++++++++----- cadence/tests/paid_auto_balance_test.cdc | 216 +++++------ cadence/tests/test_helpers_rebalance.cdc | 27 +- .../add_paid_rebalancer_to_position.cdc | 22 +- .../rebalancer/change_paid_funder.cdc | 7 +- .../rebalancer/change_paid_interval.cdc | 6 +- .../rebalancer/create_paid_rebalancer.cdc | 11 +- .../rebalancer/delete_paid_rebalancer.cdc | 16 +- .../rebalancer/fix_paid_reschedule.cdc | 18 +- 10 files changed, 417 insertions(+), 351 deletions(-) diff --git a/RebalanceArchitecture.md b/RebalanceArchitecture.md index 86dd920d..2a5f0944 100644 --- a/RebalanceArchitecture.md +++ b/RebalanceArchitecture.md @@ -1,22 +1,22 @@ -## Updated Rebalance Architecture +## Rebalance Architecture -This system **rebalances FlowALP positions on a schedule**: at a configurable interval, a rebalancer triggers the position’s `rebalance` function. **FlowALP** holds positions and exposes `rebalance`. +This system **rebalances FlowALP positions on a schedule**: at a configurable interval, a rebalancer triggers `rebalancePosition` on the pool. **FlowALP** holds positions and exposes `rebalancePosition`. **Implementation note:** In the current implementation, the FlowALP pool is `FlowALPv0.Pool`. -A **Rebalancer** when invoked, calls `rebalance` on the position and tries to schedules the next run. +A **PositionRebalancer** when invoked, calls `rebalancePosition` on the pool and tries to schedule 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. +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:** FlowALP, 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). +* **Isolation:** FlowALP, the Paid Rebalancer contract, and Supervisor are fully independent. +* **Least Privilege:** The rebalancer can *only* trigger `rebalancePosition` on the pool. +* **Resilience:** `fixReschedule()` is idempotent and permissionless — the system recovers without complex auth. ### Rebalancer config (RecurringConfig) -Each rebalancer is driven by a **RecurringConfig** that defines how and who pays for scheduled rebalances: +Each rebalancer is driven by a **RecurringConfig** set by the admin: | Field | Purpose | |-------|--------| @@ -24,36 +24,26 @@ Each rebalancer is driven by a **RecurringConfig** that defines how and who pays | **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) | +| **forceRebalance** | Whether to force rebalance regardless of current health. | | **txFunder** | **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 **txFunder** to pay the scheduler for the next scheduled transaction, and (4) on config change or cancel, refund unused fees back to **txFunder**. So **txFunder is the account that actually pays** for each scheduled rebalance. +The rebalancer uses this config to: (1) call `rebalancePosition(pid, force)` on the pool when the scheduler fires, (2) compute the next run time from `interval`, (3) withdraw FLOW from **txFunder** to pay the scheduler for the next scheduled transaction, and (4) on config change or cancel, refund unused fees back to **txFunder**. **txFunder is the account that actually pays** for each scheduled rebalance — controlled by the admin. -### Rebalancer variants +### How it works -There are two rebalancer types; they behave the same for triggering rebalances; the difference is **who supplies the config (and thus the txFunder)** and **who can change it**. +`FlowALPRebalancerPaidv1` is a **managed service**: the admin sets a default `RecurringConfig` (including a `txFunder`) and a pool capability. Anyone can call `createPaidRebalancer(positionID)` to enroll a position — no capability required from the caller. The contract: -| | **Standard Rebalancer** | **Paid Rebalancer** | -|---|---|---| -| **Who pays** | User pays (user’s txFunder) | Admin pays (admin’s txFunder 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) | +1. Creates a `PositionRebalancer` resource stored in the contract account. +2. Issues a self-capability so the scheduler can call back into it. +3. Schedules the first run using the default config. -**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. +Two safeguards prevent the permissionless creation from being abused: +1. Only one rebalancer per positionID (contract enforces this). +2. FlowALP enforces a minimum economic value per position. -### Why calls `fixReschedule()` are necessary +The admin can remove a rebalancer via `removePaidRebalancer` (cancels scheduled transactions and refunds fees to txFunder). The `defaultRecurringConfig` applies to all rebalancers and can be updated by the admin at any time via `updateDefaultRecurringConfig`. -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 txFunder 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. +### Creating a position ```mermaid sequenceDiagram @@ -62,44 +52,49 @@ sequenceDiagram participant FlowALP participant Paid as Paid Rebalancer Contract participant Supervisor - Note over admin,Paid: One-time: admin sets defaultRecurringConfig (incl. txFunder) + Note over admin,Paid: One-time: admin sets pool cap and default config (incl. txFunder) + admin->>Paid: setPoolCap(poolCap) admin->>Paid: updateDefaultRecurringConfig(config) User->>FlowALP: createPosition() - User->>Paid: createPaidRebalancer(positionRebalanceCapability) - Paid-->>User: RebalancerPaid(uuid) - User->>User: save RebalancerPaid - User->>Supervisor: addPaidRebalancer(uuid) + User->>Paid: createPaidRebalancer(positionID) + User->>Supervisor: addPaidRebalancer(positionID) ``` -### Stopping the rebalance +### Stopping the rebalancer ```mermaid sequenceDiagram - participant User - participant Paid as Paid Rebalancer + actor admin + participant Paid as Paid Rebalancer Contract 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 + admin->>Supervisor: removePaidRebalancer(positionID) + admin->>Paid: removePaidRebalancer(positionID) + Paid->>Paid: cancelAllScheduledTransactions(), destroy PositionRebalancer ``` ### While running ```mermaid sequenceDiagram - participant AB1 as AutoRebalancer1 + participant R1 as PositionRebalancer(pos1) participant FlowALP - participant AB2 as AutoRebalancer2 + participant R2 as PositionRebalancer(pos2) + participant Paid as Paid Rebalancer Contract participant SUP as Supervisor - loop every x min - AB1->>FlowALP: rebalance() + loop every x seconds + R1->>FlowALP: rebalancePosition(pos1) end - loop every y min - AB2->>FlowALP: rebalance() + loop every y seconds + R2->>FlowALP: rebalancePosition(pos2) end - loop every z min - SUP->>AB2: fixReschedule() - SUP->>AB1: fixReschedule() + loop every z seconds + SUP->>Paid: fixReschedule(pos1) + SUP->>Paid: fixReschedule(pos2) end ``` + +### Why `fixReschedule()` is necessary + +After each run, the rebalancer calls `scheduleNext()` to book the next run with `FlowTransactionScheduler`. That call can **fail** for transient reasons (e.g. `txFunder` has insufficient balance, or the scheduler is busy). When it fails, the rebalancer emits `FailedRecurringSchedule` and does **not** schedule the next execution — leaving it stuck. + +`fixReschedule()` is **idempotent**: if there is no scheduled transaction, it tries to schedule the next one; if one already exists, it does nothing. The Supervisor calls this for each registered rebalancer on every tick, recovering from transient failures automatically. diff --git a/cadence/contracts/FlowALPRebalancerPaidv1.cdc b/cadence/contracts/FlowALPRebalancerPaidv1.cdc index d2a26faf..7465afde 100644 --- a/cadence/contracts/FlowALPRebalancerPaidv1.cdc +++ b/cadence/contracts/FlowALPRebalancerPaidv1.cdc @@ -1,24 +1,42 @@ import "FlowALPv0" -import "FlowALPPositionResources" import "FlowALPModels" -import "FlowALPRebalancerv1" import "FlowTransactionScheduler" +import "FlowToken" +import "FungibleToken" +import "FlowFees" +import "DeFiActions" // FlowALPRebalancerPaidv1 — Managed rebalancer service for Flow ALP positions. // // This contract hosts scheduled rebalancers on behalf of users. Anyone may call createPaidRebalancer -// (permissionless): pass 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, txFunder, etc.). -// The admin's txFunder is used to pay for rebalance transactions. We rely on 2 things to limit how funds -// can be spent indirectly by used by creating rebalancers in this way: +// (permissionless): pass a positionID and receive a lightweight RebalancerPaid resource. The contract +// rebalances using a contract-level pool capability (set by Admin), so no per-position capability is +// required from the caller. The admin's txFunder is used to pay for rebalance transactions. We rely on +// 2 things to limit how funds can be spent indirectly by creating rebalancers in this way: // 1. This contract enforces that only one rebalancer can be created per position. // 2. FlowALP enforces a minimum economic value per position. -// 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. +// 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(positionID: UInt64) access(all) event RemovedRebalancerPaid(positionID: UInt64) + access(all) event Rebalanced( + positionID: UInt64, + force: Bool, + currentTimestamp: UFix64, + nextScheduledTimestamp: UFix64?, + scheduledTransactionID: UInt64, + ) + access(all) event FailedRecurringSchedule( + positionID: UInt64, + address: Address?, + error: String, + ) + access(all) event FixedReschedule( + positionID: UInt64, + nextScheduledTimestamp: UFix64, + ) access(all) event UpdatedDefaultRecurringConfig( interval: UInt64, priority: UInt8, @@ -27,63 +45,236 @@ access(all) contract FlowALPRebalancerPaidv1 { forceRebalance: Bool, ) + /// Configuration for how often and how the rebalancer runs, and which account pays scheduler fees. + access(all) struct RecurringConfig { + access(all) let interval: UInt64 + access(all) let priority: FlowTransactionScheduler.Priority + access(all) let executionEffort: UInt64 + /// feePaid = estimate.flowFee * estimationMargin + access(all) let estimationMargin: UFix64 + /// Whether to force rebalance even when the position is already balanced + access(all) let forceRebalance: Bool + /// Who pays for rebalance transactions. Must provide and accept FLOW. + access(contract) var txFunder: {DeFiActions.Sink, DeFiActions.Source} + + init( + interval: UInt64, + priority: FlowTransactionScheduler.Priority, + executionEffort: UInt64, + estimationMargin: UFix64, + forceRebalance: Bool, + txFunder: {DeFiActions.Sink, DeFiActions.Source} + ) { + pre { + interval > 0: + "Invalid interval: \(interval) - must be greater than 0" + UFix64(interval) < UFix64.max - getCurrentBlock().timestamp: + "Invalid interval: \(interval) - must be less than the maximum interval" + priority != FlowTransactionScheduler.Priority.High: + "Invalid priority: \(priority.rawValue) - must not be High" + txFunder.getSourceType() == Type<@FlowToken.Vault>(): + "Invalid txFunder: must provide FLOW" + txFunder.getSinkType() == Type<@FlowToken.Vault>(): + "Invalid txFunder: must accept FLOW" + } + let schedulerConfig = FlowTransactionScheduler.getConfig() + assert(executionEffort >= schedulerConfig.minimumExecutionEffort, + message: "Invalid execution effort: \(executionEffort) - must be >= minimum \(schedulerConfig.minimumExecutionEffort)") + assert(executionEffort <= schedulerConfig.maximumIndividualEffort, + message: "Invalid execution effort: \(executionEffort) - must be <= maximum \(schedulerConfig.maximumIndividualEffort)") + + self.interval = interval + self.priority = priority + self.executionEffort = executionEffort + self.estimationMargin = estimationMargin + self.forceRebalance = forceRebalance + self.txFunder = txFunder + } + } + /// Default RecurringConfig for all newly created paid rebalancers. Must be set by Admin before /// createPaidRebalancer is used. Includes txFunder, which pays for scheduled rebalance transactions. - access(all) var defaultRecurringConfig: {FlowALPRebalancerv1.RecurringConfig}? + access(all) var defaultRecurringConfig: RecurringConfig? access(all) var adminStoragePath: StoragePath + /// Pool capability used to rebalance positions by ID. Must be set by Admin before createPaidRebalancer is used. + access(self) var poolCap: Capability? + + /// PositionRebalancer — per-position scheduled rebalancer stored in this contract's account. + /// Implements TransactionHandler so FlowTransactionScheduler can invoke rebalances. + access(all) resource PositionRebalancer: FlowTransactionScheduler.TransactionHandler { + + access(all) let positionID: UInt64 + access(all) var lastRebalanceTimestamp: UFix64 + + access(self) var selfCap: Capability? + access(self) var scheduledTransactions: @{UInt64: FlowTransactionScheduler.ScheduledTransaction} + + access(all) entitlement Configure + + access(all) event ResourceDestroyed(positionID: UInt64 = self.positionID) + + init(positionID: UInt64) { + self.positionID = positionID + self.lastRebalanceTimestamp = getCurrentBlock().timestamp + self.selfCap = nil + self.scheduledTransactions <- {} + } + + access(account) fun setSelfCapability(_ cap: Capability) { + pre { cap.check(): "Invalid PositionRebalancer capability" } + self.selfCap = cap + } + + access(FlowTransactionScheduler.Execute) fun executeTransaction(id: UInt64, data: AnyStruct?) { + let pool = FlowALPRebalancerPaidv1.poolCap!.borrow()! + let config = FlowALPRebalancerPaidv1.defaultRecurringConfig! + pool.rebalancePosition(pid: self.positionID, force: config.forceRebalance) + self.lastRebalanceTimestamp = getCurrentBlock().timestamp + let nextScheduledTimestamp = self.scheduleNext() + emit Rebalanced( + positionID: self.positionID, + force: config.forceRebalance, + currentTimestamp: getCurrentBlock().timestamp, + nextScheduledTimestamp: nextScheduledTimestamp, + scheduledTransactionID: id, + ) + self.removeAllNonScheduledTransactions() + } + + /// Idempotent: schedules the next run if none is currently scheduled. + access(all) fun fixReschedule() { + self.removeAllNonScheduledTransactions() + if self.scheduledTransactions.keys.length == 0 { + if let nextTimestamp = self.scheduleNext() { + emit FixedReschedule( + positionID: self.positionID, + nextScheduledTimestamp: nextTimestamp, + ) + } + } + } + + access(FlowTransactionScheduler.Cancel) fun cancelAllScheduledTransactions() { + while self.scheduledTransactions.keys.length > 0 { + self.cancelScheduledTransaction(id: self.scheduledTransactions.keys[0]) + } + } + + access(FlowTransactionScheduler.Cancel) fun cancelScheduledTransaction(id: UInt64) { + let tx <- self.scheduledTransactions.remove(key: id)! + if tx.status() != FlowTransactionScheduler.Status.Scheduled { + destroy tx + return + } + let refund <- FlowTransactionScheduler.cancel(scheduledTx: <-tx) + FlowALPRebalancerPaidv1.defaultRecurringConfig!.txFunder.depositCapacity(from: &refund as auth(FungibleToken.Withdraw) &{FungibleToken.Vault}) + if refund.balance > 0.0 { + panic("can't deposit full refund back to txFunder, remaining: \(refund.balance)") + } + destroy refund + } + + access(all) view fun nextExecutionTimestamp(): UFix64 { + let config = FlowALPRebalancerPaidv1.defaultRecurringConfig! + if UInt64(UFix64.max) - UInt64(self.lastRebalanceTimestamp) <= config.interval { + return UFix64.max + } + var nextTimestamp = self.lastRebalanceTimestamp + UFix64(config.interval) + let nextPossible = getCurrentBlock().timestamp + 1.0 + if nextTimestamp < nextPossible { + nextTimestamp = nextPossible + } + return nextTimestamp + } + + access(self) fun scheduleNext(): UFix64? { + let config = FlowALPRebalancerPaidv1.defaultRecurringConfig! + let nextTimestamp = self.nextExecutionTimestamp() + let flowFee = self.calculateFee(priority: config.priority, executionEffort: config.executionEffort) + let feeWithMargin = flowFee * config.estimationMargin + let minimumAvailable = config.txFunder.minimumAvailable() + if minimumAvailable < feeWithMargin { + emit FailedRecurringSchedule( + positionID: self.positionID, + address: self.owner?.address, + error: "insufficient fees available, expected: \(feeWithMargin) but available: \(minimumAvailable)", + ) + return nil + } + let fees <- config.txFunder.withdrawAvailable(maxAmount: feeWithMargin) as! @FlowToken.Vault + if fees.balance != feeWithMargin { + panic("invalid fees balance: \(fees.balance) - expected: \(feeWithMargin)") + } + let tx <- FlowTransactionScheduler.schedule( + handlerCap: self.selfCap!, + data: nil, + timestamp: nextTimestamp, + priority: config.priority, + executionEffort: config.executionEffort, + fees: <-fees + ) + self.scheduledTransactions[tx.id] <-! tx + return nextTimestamp + } + + access(self) fun calculateFee(priority: FlowTransactionScheduler.Priority, executionEffort: UInt64): UFix64 { + let baseFee = FlowFees.computeFees(inclusionEffort: 1.0, executionEffort: UFix64(executionEffort)/100_000_000.0) + let scaledExecutionFee = baseFee * FlowTransactionScheduler.getConfig().priorityFeeMultipliers[priority]! + let inclusionFee = 0.00001 + return scaledExecutionFee + inclusionFee + } + + access(self) fun removeAllNonScheduledTransactions() { + for id in self.scheduledTransactions.keys { + let tx = (&self.scheduledTransactions[id] as &FlowTransactionScheduler.ScheduledTransaction?)! + if tx.status() != FlowTransactionScheduler.Status.Scheduled { + destroy self.scheduledTransactions.remove(key: id) + } + } + } + } /// Create a paid rebalancer for the given position. Permissionless: anyone may call this. - /// Uses defaultRecurringConfig (must be set by Admin). Returns a RebalancerPaid resource; the - /// underlying Rebalancer is stored in this contract and the first run is scheduled. Caller should - /// register the returned positionID with a Supervisor. - access(all) fun createPaidRebalancer( - positionRebalanceCapability: Capability, - ): @RebalancerPaid { - assert(positionRebalanceCapability.check(), message: "Invalid position rebalance capability") - let positionID = positionRebalanceCapability.borrow()!.id - let rebalancer <- FlowALPRebalancerv1.createRebalancer( - recurringConfig: self.defaultRecurringConfig!, - positionRebalanceCapability: positionRebalanceCapability + /// Uses defaultRecurringConfig and the contract's pool capability (both must be set by Admin). + /// Returns a RebalancerPaid resource; the underlying PositionRebalancer is stored in this contract + /// and the first run is scheduled. + access(all) fun createPaidRebalancer(positionID: UInt64) { + let rebalancer <- create PositionRebalancer( + positionID: positionID ) - // will panic if the rebalancer already exists self.storeRebalancer(rebalancer: <-rebalancer, positionID: positionID) self.setSelfCapability(positionID: positionID).fixReschedule() emit CreatedRebalancerPaid(positionID: positionID) - return <- create RebalancerPaid(positionID: positionID) } - /// Admin resource: controls default config and per-rebalancer config; can remove paid rebalancers. + /// Admin resource: controls default config, pool capability, and individual rebalancers. access(all) resource Admin { - /// Set the default RecurringConfig for all newly created paid rebalancers (interval, txFunder, etc.). - access(all) fun updateDefaultRecurringConfig(recurringConfig: {FlowALPRebalancerv1.RecurringConfig}) { - FlowALPRebalancerPaidv1.defaultRecurringConfig = recurringConfig + + /// Set the pool capability used to rebalance positions. Must be called before createPaidRebalancer. + access(all) fun setPoolCap(_ cap: Capability) { + pre { cap.check(): "Invalid pool capability" } + FlowALPRebalancerPaidv1.poolCap = cap + } + + /// Set the default RecurringConfig applied to all newly created paid rebalancers. + access(all) fun updateDefaultRecurringConfig(_ config: RecurringConfig) { + FlowALPRebalancerPaidv1.defaultRecurringConfig = config emit UpdatedDefaultRecurringConfig( - interval: recurringConfig.getInterval(), - priority: recurringConfig.getPriority().rawValue, - executionEffort: recurringConfig.getExecutionEffort(), - estimationMargin: recurringConfig.getEstimationMargin(), - forceRebalance: recurringConfig.getForceRebalance(), + interval: config.interval, + priority: config.priority.rawValue, + executionEffort: config.executionEffort, + estimationMargin: config.estimationMargin, + forceRebalance: config.forceRebalance, ) } - /// Borrow a paid rebalancer with Configure and ERebalance auth (e.g. for setRecurringConfig or rebalance). - access(all) fun borrowAuthorizedRebalancer( - positionID: UInt64, - ): auth(FlowALPModels.ERebalance, FlowALPRebalancerv1.Rebalancer.Configure) &FlowALPRebalancerv1.Rebalancer? { + /// Borrow a rebalancer with Configure entitlement to call setRecurringConfig. + access(all) fun borrowAuthorizedRebalancer(positionID: UInt64): auth(PositionRebalancer.Configure) &PositionRebalancer? { return FlowALPRebalancerPaidv1.borrowRebalancer(positionID: positionID) } - /// Update the RecurringConfig for a specific paid rebalancer (interval, txFunder, etc.). - access(all) fun updateRecurringConfig( - positionID: UInt64, - recurringConfig: {FlowALPRebalancerv1.RecurringConfig}) - { - let rebalancer = FlowALPRebalancerPaidv1.borrowRebalancer(positionID: positionID)! - rebalancer.setRecurringConfig(recurringConfig) - } - /// Remove a paid rebalancer: cancel scheduled transactions (refund to txFunder) and destroy it. - access(account) fun removePaidRebalancer(positionID: UInt64) { + access(all) fun removePaidRebalancer(positionID: UInt64) { FlowALPRebalancerPaidv1.removePaidRebalancer(positionID: positionID) emit RemovedRebalancerPaid(positionID: positionID) } @@ -91,33 +282,9 @@ access(all) contract FlowALPRebalancerPaidv1 { 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 position id (from positionRebalanceCapability) this paid rebalancer is associated with. - access(all) var positionID: UInt64 - - init(positionID: UInt64) { - self.positionID = positionID - } - - /// Stop and remove the paid rebalancer; scheduled transactions are cancelled and fees refunded to the admin txFunder. - access(Delete) fun delete() { - FlowALPRebalancerPaidv1.removePaidRebalancer(positionID: self.positionID) - } - - /// Idempotent: if no next run is scheduled, try to schedule it (e.g. after a transient failure). - access(all) fun fixReschedule() { - let _ = FlowALPRebalancerPaidv1.fixReschedule(positionID: self.positionID) - } - } - - /// 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). - /// Returns true if the rebalancer was found and processed, false if the UUID is stale (rebalancer no longer exists). - access(all) fun fixReschedule( - positionID: UInt64, - ): Bool { + /// Idempotent: if the rebalancer exists and has no scheduled run, schedule the next one. + /// Returns true if the rebalancer was found, false if it no longer exists (caller can prune stale refs). + access(all) fun fixReschedule(positionID: UInt64): Bool { if let rebalancer = FlowALPRebalancerPaidv1.borrowRebalancer(positionID: positionID) { rebalancer.fixReschedule() return true @@ -125,46 +292,31 @@ access(all) contract FlowALPRebalancerPaidv1 { return false } - /// Storage path where a user would store their RebalancerPaid for the given position (convention for discovery). - access(all) view fun getPaidRebalancerPath( - positionID: UInt64, - ): StoragePath { + /// Suggested storage path for a user's RebalancerPaid for the given position. + access(all) view fun getPaidRebalancerPath(positionID: UInt64): StoragePath { return StoragePath(identifier: "FlowALP.RebalancerPaidv1_\(self.account.address)_\(positionID)")! } - access(self) fun borrowRebalancer( - positionID: UInt64, - ): auth(FlowALPModels.ERebalance, FlowALPRebalancerv1.Rebalancer.Configure) &FlowALPRebalancerv1.Rebalancer? { - return self.account.storage.borrow(from: self.getPath(positionID: positionID)) + access(self) fun borrowRebalancer(positionID: UInt64): auth(PositionRebalancer.Configure) &PositionRebalancer? { + return self.account.storage.borrow(from: self.getPath(positionID: positionID)) } access(self) fun removePaidRebalancer(positionID: UInt64) { - let rebalancer <- self.account.storage.load<@FlowALPRebalancerv1.Rebalancer>(from: self.getPath(positionID: positionID)) + let rebalancer <- self.account.storage.load<@PositionRebalancer>(from: self.getPath(positionID: positionID)) rebalancer?.cancelAllScheduledTransactions() destroy <- rebalancer } - access(self) fun storeRebalancer( - rebalancer: @FlowALPRebalancerv1.Rebalancer, - positionID: UInt64, - ) { + access(self) fun storeRebalancer(rebalancer: @PositionRebalancer, positionID: UInt64) { let path = self.getPath(positionID: positionID) - if self.account.storage.borrow<&FlowALPRebalancerv1.Rebalancer>(from: path) != nil { - panic("rebalancer already exists") + if self.account.storage.borrow<&PositionRebalancer>(from: path) != nil { + panic("rebalancer already exists for positionID \(positionID)") } 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( - positionID: UInt64, - ) : auth(FlowALPModels.ERebalance, FlowALPRebalancerv1.Rebalancer.Configure) &FlowALPRebalancerv1.Rebalancer { + access(self) fun setSelfCapability(positionID: UInt64): auth(PositionRebalancer.Configure) &PositionRebalancer { let selfCap = self.account.capabilities.storage.issue(self.getPath(positionID: positionID)) - // 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(positionID: positionID)! rebalancer.setSelfCapability(selfCap) return rebalancer @@ -177,7 +329,7 @@ access(all) contract FlowALPRebalancerPaidv1 { init() { self.adminStoragePath = StoragePath(identifier: "FlowALP.RebalancerPaidv1.Admin")! self.defaultRecurringConfig = nil - let admin <- create Admin() - self.account.storage.save(<-admin, to: self.adminStoragePath) + self.poolCap = nil + self.account.storage.save(<- create Admin(), to: self.adminStoragePath) } } diff --git a/cadence/tests/paid_auto_balance_test.cdc b/cadence/tests/paid_auto_balance_test.cdc index 7873f9c5..8e8d6fa4 100644 --- a/cadence/tests/paid_auto_balance_test.cdc +++ b/cadence/tests/paid_auto_balance_test.cdc @@ -3,7 +3,6 @@ import BlockchainHelpers import "test_helpers.cdc" import "test_helpers_rebalance.cdc" -import "FlowALPRebalancerv1" import "FlowALPRebalancerPaidv1" import "FlowTransactionScheduler" import "MOET" @@ -17,8 +16,6 @@ access(all) let flowVaultStoragePath = /storage/flowTokenVault access(all) let flowTokenIdentifier = "A.0000000000000003.FlowToken.Vault" access(all) let positionStoragePath = /storage/position -access(all) let paidRebalancerStoragePath = /storage/paidRebalancer -access(all) let paidRebalancer2StoragePath = /storage/paidRebalancer2 access(all) let supervisorStoragePath = /storage/supervisor access(all) let cronHandlerStoragePath = /storage/myRecurringTaskHandler @@ -44,8 +41,8 @@ access(all) fun setup() { createPaidRebalancer(signer: protocolAccount, paidRebalancerAdminStoragePath: FlowALPRebalancerPaidv1.adminStoragePath) createPositionNotManaged(signer: userAccount, amount: 100.0, vaultStoragePath: flowVaultStoragePath, pushToDrawDownSink: false, positionStoragePath: positionStoragePath) depositToPositionNotManaged(signer: userAccount, positionStoragePath: positionStoragePath, amount: 100.0, vaultStoragePath: flowVaultStoragePath, pushToDrawDownSink: false) - addPaidRebalancerToPosition(signer: userAccount, positionStoragePath: positionStoragePath, paidRebalancerStoragePath: paidRebalancerStoragePath) - let evts = Test.eventsOfType(Type()) + addPaidRebalancerToPosition(signer: userAccount, positionStoragePath: positionStoragePath) + let evts = Test.eventsOfType(Type()) Test.assertEqual(1, evts.length) // one paid rebalancer created for the position createSupervisor( signer: userAccount, @@ -69,22 +66,22 @@ access(all) fun test_on_time() { // should execute every 100 seconds Test.moveTime(by: 90.0) Test.commitBlock() - var evts = Test.eventsOfType(Type()) + var evts = Test.eventsOfType(Type()) Test.assertEqual(0, evts.length) Test.moveTime(by: 10.0) Test.commitBlock() - evts = Test.eventsOfType(Type()) + evts = Test.eventsOfType(Type()) Test.assertEqual(1, evts.length) Test.moveTime(by: 90.0) Test.commitBlock() - evts = Test.eventsOfType(Type()) + evts = Test.eventsOfType(Type()) Test.assertEqual(1, evts.length) Test.moveTime(by: 10.0) Test.commitBlock() - evts = Test.eventsOfType(Type()) + evts = Test.eventsOfType(Type()) Test.assertEqual(2, evts.length) } @@ -92,51 +89,59 @@ access(all) fun test_delayed_rebalance() { // should execute every 100 seconds Test.moveTime(by: 1000.0) Test.commitBlock() - var evts = Test.eventsOfType(Type()) + var evts = Test.eventsOfType(Type()) Test.assertEqual(1, evts.length) Test.moveTime(by: 1.0) Test.commitBlock() // we do NOT expect more rebalances here! - evts = Test.eventsOfType(Type()) + evts = Test.eventsOfType(Type()) Test.assertEqual(1, evts.length) Test.moveTime(by: 99.0) Test.commitBlock() - evts = Test.eventsOfType(Type()) + evts = Test.eventsOfType(Type()) Test.assertEqual(2, evts.length) } access(all) fun test_fix_reschedule_idempotent() { - // when initially created, it should emit an fix reschedule event to get started - var evts = Test.eventsOfType(Type()) + let createdEvts = Test.eventsOfType(Type()) + Test.assertEqual(1, createdEvts.length) + let created = createdEvts[0] as! FlowALPRebalancerPaidv1.CreatedRebalancerPaid + + // when initially created, it should emit a FixedReschedule event to get started + var evts = Test.eventsOfType(Type()) Test.assertEqual(1, evts.length) - fixPaidReschedule(signer: userAccount, positionID: nil, paidRebalancerStoragePath: paidRebalancerStoragePath) - fixPaidReschedule(signer: userAccount, positionID: nil, paidRebalancerStoragePath: paidRebalancerStoragePath) + fixPaidReschedule(positionID: created.positionID) + fixPaidReschedule(positionID: created.positionID) Test.moveTime(by: 10.0) Test.commitBlock() - fixPaidReschedule(signer: userAccount, positionID: nil, paidRebalancerStoragePath: paidRebalancerStoragePath) + fixPaidReschedule(positionID: created.positionID) Test.moveTime(by: 1000.0) Test.commitBlock() - fixPaidReschedule(signer: userAccount, positionID: nil, paidRebalancerStoragePath: paidRebalancerStoragePath) - fixPaidReschedule(signer: userAccount, positionID: nil, paidRebalancerStoragePath: paidRebalancerStoragePath) + fixPaidReschedule(positionID: created.positionID) + fixPaidReschedule(positionID: created.positionID) - evts = Test.eventsOfType(Type()) + evts = Test.eventsOfType(Type()) Test.assertEqual(1, evts.length) } access(all) fun test_fix_reschedule_no_funds() { + let createdEvts = Test.eventsOfType(Type()) + Test.assertEqual(1, createdEvts.length) + let created = createdEvts[0] as! FlowALPRebalancerPaidv1.CreatedRebalancerPaid + Test.moveTime(by: 100.0) Test.commitBlock() - var evts = Test.eventsOfType(Type()) + var evts = Test.eventsOfType(Type()) Test.assertEqual(1, evts.length) // drain the funding contract so the transaction reverts @@ -147,76 +152,39 @@ access(all) fun test_fix_reschedule_no_funds() { Test.commitBlock() // it still executed once but should have no transaction scheduled - evts = Test.eventsOfType(Type()) + evts = Test.eventsOfType(Type()) Test.assertEqual(2, evts.length) Test.moveTime(by: 1000.0) Test.commitBlock() - evts = Test.eventsOfType(Type()) + evts = Test.eventsOfType(Type()) Test.assertEqual(2, evts.length) // now we fix the missing funds and call fix reschedule mintFlow(to: protocolAccount, amount: 1000.0) - fixPaidReschedule(signer: userAccount, positionID: nil, paidRebalancerStoragePath: paidRebalancerStoragePath) + fixPaidReschedule(positionID: created.positionID) Test.moveTime(by: 1.0) Test.commitBlock() - evts = Test.eventsOfType(Type()) + evts = Test.eventsOfType(Type()) Test.assertEqual(3, evts.length) - evts = Test.eventsOfType(Type()) + evts = Test.eventsOfType(Type()) Test.assertEqual(2, evts.length) } -// FLO-17 regression: when setRecurringConfig is called, cancel must use the OLD config's funder -// so that pre-paid fees are refunded to the original payer, not the new funder. -access(all) fun test_flo17_refund_goes_to_old_funder_not_new_funder() { - // The rebalancer was created in setup() with protocolAccount as the txFunder. - // A scheduled transaction with fees pre-paid from protocolAccount already exists. - let createdEvts = Test.eventsOfType(Type()) - Test.assertEqual(1, createdEvts.length) - let e = createdEvts[0] as! FlowALPRebalancerPaidv1.CreatedRebalancerPaid - - // Create a new funder account — this should NOT receive the refund for previously paid fees. - let newFunderAccount = Test.createAccount() - let _ = mintFlow(to: newFunderAccount, amount: 100.0) - - let oldFunderBalanceBefore = getBalance(address: protocolAccount.address, vaultPublicPath: /public/flowTokenBalance)! - let newFunderBalanceBefore = getBalance(address: newFunderAccount.address, vaultPublicPath: /public/flowTokenBalance)! - - // Switch the recurring config to use newFunderAccount as the fee payer going forward. - // This calls setRecurringConfig, which cancels the existing scheduled tx and refunds its fee. - changePaidFunder( - adminSigner: protocolAccount, - newFunderSigner: newFunderAccount, - positionID: e.positionID, - interval: 100, - expectFailure: false - ) - - let oldFunderBalanceAfter = getBalance(address: protocolAccount.address, vaultPublicPath: /public/flowTokenBalance)! - let newFunderBalanceAfter = getBalance(address: newFunderAccount.address, vaultPublicPath: /public/flowTokenBalance)! - - // The pre-paid fee must be refunded to the OLD funder (protocolAccount), not the new one. - Test.assert( - oldFunderBalanceAfter > oldFunderBalanceBefore, - message: "FLO-17: old funder should receive refund on config change, balance before=\(oldFunderBalanceBefore) after=\(oldFunderBalanceAfter)" - ) - // New funder must not receive a windfall - Test.assert(newFunderBalanceBefore >= newFunderBalanceAfter) -} - access(all) fun test_two_paid_rebalancers_same_position() { // One paid rebalancer is created in setup for the position. var evts = Test.eventsOfType(Type()) Test.assertEqual(1, evts.length) - let addRes: Test.TransactionResult = _executeTransaction( - "./transactions/rebalancer/add_paid_rebalancer_to_position.cdc", - [positionStoragePath, paidRebalancer2StoragePath], - userAccount - ) - // creating a second paid rebalancer should fail + let addRes: Test.TransactionResult = Test.executeTransaction(Test.Transaction( + code: Test.readFile("./transactions/rebalancer/add_paid_rebalancer_to_position.cdc"), + authorizers: [userAccount.address], + signers: [userAccount], + arguments: [positionStoragePath] + )) + // creating a second paid rebalancer for the same position must fail Test.expect(addRes, Test.beFailed()) Test.assertError(addRes, errorMessage: "rebalancer already exists") @@ -225,63 +193,64 @@ access(all) fun test_two_paid_rebalancers_same_position() { } access(all) fun test_change_recurring_config_as_user() { - var evts = Test.eventsOfType(Type()) + let evts = Test.eventsOfType(Type()) Test.assertEqual(1, evts.length) - let e = evts[0] as! FlowALPRebalancerv1.CreatedRebalancer + let e = evts[0] as! FlowALPRebalancerPaidv1.CreatedRebalancerPaid changePaidInterval(signer: userAccount, positionID: e.positionID, interval: 100, expectFailure: true) } access(all) fun test_change_recurring_config() { - Test.moveTime(by: 150.0) - Test.commitBlock() - - var evts = Test.eventsOfType(Type()) - Test.assertEqual(1, evts.length) - let e = evts[0] as! FlowALPRebalancerv1.Rebalanced - - changePaidInterval(signer: protocolAccount, positionID: e.positionID, interval: 1000, expectFailure: false) + let createdEvts = Test.eventsOfType(Type()) + Test.assertEqual(1, createdEvts.length) + let created = createdEvts[0] as! FlowALPRebalancerPaidv1.CreatedRebalancerPaid - Test.moveTime(by: 980.0) + // Initial interval=100. First rebalance fires at T+100, schedules next at T+200. + Test.moveTime(by: 100.0) Test.commitBlock() - evts = Test.eventsOfType(Type()) + var evts = Test.eventsOfType(Type()) Test.assertEqual(1, evts.length) - Test.moveTime(by: 20.0) - Test.commitBlock() + // Change to interval=1000. The already-scheduled tx at T+200 is NOT cancelled. + changePaidInterval(signer: protocolAccount, positionID: created.positionID, interval: 1000, expectFailure: false) - evts = Test.eventsOfType(Type()) + // T+200: old-interval tx fires; next is now scheduled at T+1200 (new interval=1000). + Test.moveTime(by: 100.0) + Test.commitBlock() + evts = Test.eventsOfType(Type()) Test.assertEqual(2, evts.length) - changePaidInterval(signer: protocolAccount, positionID: e.positionID, interval: 50, expectFailure: false) - - Test.moveTime(by: 45.0) + // T+1199: new interval not yet elapsed. + Test.moveTime(by: 999.0) Test.commitBlock() - - evts = Test.eventsOfType(Type()) + evts = Test.eventsOfType(Type()) Test.assertEqual(2, evts.length) - Test.moveTime(by: 5.0) + // T+1200: new interval fires. + Test.moveTime(by: 1.0) Test.commitBlock() - - evts = Test.eventsOfType(Type()) + evts = Test.eventsOfType(Type()) Test.assertEqual(3, evts.length) } access(all) fun test_delete_rebalancer() { + let createdEvts = Test.eventsOfType(Type()) + Test.assertEqual(1, createdEvts.length) + let created = createdEvts[0] as! FlowALPRebalancerPaidv1.CreatedRebalancerPaid + Test.moveTime(by: 100.0) Test.commitBlock() - var evts = Test.eventsOfType(Type()) + var evts = Test.eventsOfType(Type()) Test.assertEqual(1, evts.length) - deletePaidRebalancer(signer: userAccount, paidRebalancerStoragePath: paidRebalancerStoragePath) + deletePaidRebalancer(signer: protocolAccount, positionID: created.positionID) Test.moveTime(by: 1000.0) Test.commitBlock() - evts = Test.eventsOfType(Type()) + evts = Test.eventsOfType(Type()) Test.assertEqual(1, evts.length) } @@ -289,12 +258,11 @@ access(all) fun test_public_fix_reschedule() { Test.moveTime(by: 100.0) Test.commitBlock() - var evts = Test.eventsOfType(Type()) + var evts = Test.eventsOfType(Type()) Test.assertEqual(1, evts.length) - let e = evts[0] as! FlowALPRebalancerv1.Rebalanced + let e = evts[0] as! FlowALPRebalancerPaidv1.Rebalanced - let randomAccount = Test.createAccount() - fixPaidReschedule(signer: randomAccount, positionID: e.positionID, paidRebalancerStoragePath: paidRebalancerStoragePath) + fixPaidReschedule(positionID: e.positionID) } access(all) fun test_supervisor_executed() { @@ -316,43 +284,33 @@ access(all) fun test_supervisor_executed() { /// fixReschedule(uuid:) force-unwrapped borrowRebalancer(uuid)! which panicked on a stale UUID, /// reverting the whole executeTransaction and blocking recovery for all other rebalancers. access(all) fun test_supervisor_stale_uuid_does_not_panic() { - // Let the initial cron tick fire first (supervisor set is empty, so it does nothing - // except emit Executed). This avoids a race where the cron fires during the add/delete - // transactions below before the stale state is set up. - Test.moveTime(by: 100.0) - Test.commitBlock() - - let initialExecutedEvts = Test.eventsOfType(Type()) - Test.assert(initialExecutedEvts.length >= 1, message: "Initial cron tick should have fired") - - // Get the UUID of the paid rebalancer created during setup. - let createdEvts = Test.eventsOfType(Type()) + // Get the positionID of the paid rebalancer created during setup. + let createdEvts = Test.eventsOfType(Type()) Test.assertEqual(1, createdEvts.length) - let created = createdEvts[0] as! FlowALPRebalancerv1.CreatedRebalancer + let created = createdEvts[0] as! FlowALPRebalancerPaidv1.CreatedRebalancerPaid - // Register the UUID with the Supervisor so it will call fixReschedule on it each tick. + // Register the positionID with the Supervisor so it will call fixReschedule on it each tick. addPaidRebalancerToSupervisor(signer: userAccount, positionID: created.positionID, supervisorStoragePath: supervisorStoragePath) - // Delete the paid rebalancer WITHOUT removing its UUID from the Supervisor — this leaves a - // stale UUID in the Supervisor's paidRebalancers set, simulating the FLO-27 bug scenario. - deletePaidRebalancer(signer: userAccount, paidRebalancerStoragePath: paidRebalancerStoragePath) + // Delete the paid rebalancer WITHOUT removing its positionID from the Supervisor — this leaves a + // stale entry in the Supervisor's paidRebalancers set, simulating the FLO-27 bug scenario. + deletePaidRebalancer(signer: protocolAccount, positionID: created.positionID) - // Advance time to trigger the next Supervisor tick. - Test.moveTime(by: 60.0 * 60.0) + // Advance time to trigger the Supervisor's scheduled tick. + Test.moveTime(by: 60.0 * 60.0 * 10.0) Test.commitBlock() - // The Supervisor must have executed without panicking. If fixReschedule force-unwrapped - // the missing rebalancer the entire transaction would revert and Executed would not be emitted. + // The Supervisor must have executed without panicking. let executedEvts = Test.eventsOfType(Type()) Test.assert(executedEvts.length >= 2, message: "Supervisor should have executed at least 2 times (initial + stale prune)") - // The stale UUID must have been pruned from the Supervisor's set. + // The stale positionID must have been pruned from the Supervisor's set. let removedEvts = Test.eventsOfType(Type()) Test.assertEqual(1, removedEvts.length) let removed = removedEvts[0] as! FlowALPSupervisorv1.RemovedPaidRebalancer Test.assertEqual(created.positionID, removed.positionID) - // A second tick should not emit another RemovedPaidRebalancer — the UUID was already cleaned up. + // A second tick should not emit another RemovedPaidRebalancer — already cleaned up. Test.moveTime(by: 60.0 * 60.0) Test.commitBlock() let removedEvts2 = Test.eventsOfType(Type()) @@ -366,9 +324,9 @@ access(all) fun test_supervisor() { var evts = Test.eventsOfType(Type()) Test.assertEqual(1, evts.length) - evts = Test.eventsOfType(Type()) + evts = Test.eventsOfType(Type()) Test.assertEqual(1, evts.length) - let e = evts[0] as! FlowALPRebalancerv1.Rebalanced + let e = evts[0] as! FlowALPRebalancerPaidv1.Rebalanced addPaidRebalancerToSupervisor(signer: userAccount, positionID: e.positionID, supervisorStoragePath: supervisorStoragePath) @@ -380,7 +338,7 @@ access(all) fun test_supervisor() { Test.commitBlock() // it still executed once but should have no transaction scheduled - evts = Test.eventsOfType(Type()) + evts = Test.eventsOfType(Type()) Test.assertEqual(2, evts.length) Test.moveTime(by: 1000.0) @@ -388,20 +346,20 @@ access(all) fun test_supervisor() { // now we fix the missing funds and call fix reschedule mintFlow(to: protocolAccount, amount: 1000.0) - Test.moveTime(by: 60.0* 100.0) + Test.moveTime(by: 60.0 * 100.0) Test.commitBlock() // now supervisor will fix the rebalancer evts = Test.eventsOfType(Type()) Test.assert(evts.length >= 2, message: "Supervisor should have executed at least 2 times") - evts = Test.eventsOfType(Type()) + evts = Test.eventsOfType(Type()) Test.assertEqual(2, evts.length) Test.moveTime(by: 10.0) Test.commitBlock() // now rebalancer could run the transaction again - evts = Test.eventsOfType(Type()) + evts = Test.eventsOfType(Type()) Test.assertEqual(3, evts.length) -} \ No newline at end of file +} diff --git a/cadence/tests/test_helpers_rebalance.cdc b/cadence/tests/test_helpers_rebalance.cdc index de510cfb..ee697137 100644 --- a/cadence/tests/test_helpers_rebalance.cdc +++ b/cadence/tests/test_helpers_rebalance.cdc @@ -14,12 +14,11 @@ fun _executeTransaction(_ path: String, _ args: [AnyStruct], _ signer: Test.Test access(all) fun addPaidRebalancerToPosition( signer: Test.TestAccount, - positionStoragePath: StoragePath, - paidRebalancerStoragePath: StoragePath + positionStoragePath: StoragePath ) { let addRes = _executeTransaction( "./transactions/rebalancer/add_paid_rebalancer_to_position.cdc", - [positionStoragePath, paidRebalancerStoragePath], + [positionStoragePath], signer ) Test.expect(addRes, Test.beSucceeded()) @@ -105,26 +104,24 @@ fun createSupervisor( access(all) fun deletePaidRebalancer( signer: Test.TestAccount, - paidRebalancerStoragePath: StoragePath + positionID: UInt64 ) { let setRes = _executeTransaction( "./transactions/rebalancer/delete_paid_rebalancer.cdc", - [paidRebalancerStoragePath], + [positionID], signer ) Test.expect(setRes, Test.beSucceeded()) } access(all) -fun fixPaidReschedule( - signer: Test.TestAccount, - positionID: UInt64?, - paidRebalancerStoragePath: StoragePath -) { - let setRes = _executeTransaction( - "./transactions/rebalancer/fix_paid_reschedule.cdc", - [positionID, paidRebalancerStoragePath], - signer +fun fixPaidReschedule(positionID: UInt64) { + let txn = Test.Transaction( + code: Test.readFile("./transactions/rebalancer/fix_paid_reschedule.cdc"), + authorizers: [], + signers: [], + arguments: [positionID] ) - Test.expect(setRes, Test.beSucceeded()) + let result = Test.executeTransaction(txn) + Test.expect(result, Test.beSucceeded()) } diff --git a/cadence/tests/transactions/rebalancer/add_paid_rebalancer_to_position.cdc b/cadence/tests/transactions/rebalancer/add_paid_rebalancer_to_position.cdc index 1c1271d5..3b61b279 100644 --- a/cadence/tests/transactions/rebalancer/add_paid_rebalancer_to_position.cdc +++ b/cadence/tests/transactions/rebalancer/add_paid_rebalancer_to_position.cdc @@ -1,23 +1,9 @@ -import "FlowALPv0" import "FlowALPPositionResources" -import "FlowALPModels" -import "FlowALPRebalancerv1" import "FlowALPRebalancerPaidv1" -transaction(positionStoragePath: StoragePath, paidRebalancerStoragePath: StoragePath) { - let signer: auth(Storage, IssueStorageCapabilityController, SaveValue) &Account - - prepare(signer: auth(Storage, IssueStorageCapabilityController, SaveValue) &Account) { - self.signer = signer - } - - execute { - let rebalanceCap = self.signer.capabilities.storage.issue( - positionStoragePath - ) - let paidRebalancer <- FlowALPRebalancerPaidv1.createPaidRebalancer( - positionRebalanceCapability: rebalanceCap - ) - self.signer.storage.save(<-paidRebalancer, to: paidRebalancerStoragePath) +transaction(positionStoragePath: StoragePath) { + prepare(signer: auth(Storage) &Account) { + let position = signer.storage.borrow<&FlowALPPositionResources.Position>(from: positionStoragePath)! + FlowALPRebalancerPaidv1.createPaidRebalancer(positionID: position.id) } } diff --git a/cadence/tests/transactions/rebalancer/change_paid_funder.cdc b/cadence/tests/transactions/rebalancer/change_paid_funder.cdc index 5918bc17..17ab577a 100644 --- a/cadence/tests/transactions/rebalancer/change_paid_funder.cdc +++ b/cadence/tests/transactions/rebalancer/change_paid_funder.cdc @@ -1,11 +1,10 @@ import "FungibleToken" import "FungibleTokenConnectors" -import "FlowALPRebalancerv1" import "FlowALPRebalancerPaidv1" import "FlowToken" import "FlowTransactionScheduler" -// Changes the recurring config for a paid rebalancer, using a different account as txFunder. +// Changes the default recurring config, using a different account as txFunder. // `admin` must hold FlowALPRebalancerPaidv1.Admin; `newFunder` provides the new fee vault. transaction(positionID: UInt64, interval: UInt64) { let adminCap: Capability<&FlowALPRebalancerPaidv1.Admin> @@ -27,7 +26,7 @@ transaction(positionID: UInt64, interval: UInt64) { let sinkSource = FungibleTokenConnectors.VaultSinkAndSource( min: nil, max: nil, vault: self.newFunderVaultCap, uniqueID: nil ) - let config = FlowALPRebalancerv1.RecurringConfigImplv1( + let config = FlowALPRebalancerPaidv1.RecurringConfig( interval: interval, priority: FlowTransactionScheduler.Priority.Medium, executionEffort: 1000, @@ -35,6 +34,6 @@ transaction(positionID: UInt64, interval: UInt64) { forceRebalance: false, txFunder: sinkSource ) - self.adminCap.borrow()!.updateRecurringConfig(positionID: positionID, recurringConfig: config) + self.adminCap.borrow()!.updateDefaultRecurringConfig(config) } } diff --git a/cadence/tests/transactions/rebalancer/change_paid_interval.cdc b/cadence/tests/transactions/rebalancer/change_paid_interval.cdc index 99d091f4..af5c440f 100644 --- a/cadence/tests/transactions/rebalancer/change_paid_interval.cdc +++ b/cadence/tests/transactions/rebalancer/change_paid_interval.cdc @@ -1,6 +1,5 @@ import "FungibleToken" import "FungibleTokenConnectors" -import "FlowALPRebalancerv1" import "FlowALPRebalancerPaidv1" import "FlowToken" import "FlowTransactionScheduler" @@ -21,8 +20,7 @@ transaction(positionID: UInt64, interval: UInt64) { execute { let sinkSource = FungibleTokenConnectors.VaultSinkAndSource(min: nil, max: nil, vault: self.vaultCapability, uniqueID: nil) - let borrowedRebalancer = self.adminPaidRebalancerCap.borrow()!.borrowAuthorizedRebalancer(positionID: positionID)! - let config = FlowALPRebalancerv1.RecurringConfigImplv1( + let config = FlowALPRebalancerPaidv1.RecurringConfig( interval: interval, priority: FlowTransactionScheduler.Priority.Medium, executionEffort: 1000, @@ -30,6 +28,6 @@ transaction(positionID: UInt64, interval: UInt64) { forceRebalance: false, txFunder: sinkSource ) - borrowedRebalancer.setRecurringConfig(config) + self.adminPaidRebalancerCap.borrow()!.updateDefaultRecurringConfig(config) } } diff --git a/cadence/tests/transactions/rebalancer/create_paid_rebalancer.cdc b/cadence/tests/transactions/rebalancer/create_paid_rebalancer.cdc index 869cdd7f..64f42d04 100644 --- a/cadence/tests/transactions/rebalancer/create_paid_rebalancer.cdc +++ b/cadence/tests/transactions/rebalancer/create_paid_rebalancer.cdc @@ -1,25 +1,28 @@ import "FlowToken" import "FungibleToken" import "FlowALPRebalancerPaidv1" -import "FlowALPRebalancerv1" +import "FlowALPModels" +import "FlowALPv0" import "FlowTransactionScheduler" import "FungibleTokenConnectors" transaction(paidRebalancerAdminStoragePath: StoragePath) { - // let signer: auth(Capabilities, BorrowValue, IssueStorageCapabilityController) &Account let admin: &FlowALPRebalancerPaidv1.Admin let vaultCapability: Capability prepare(signer: auth(Capabilities, BorrowValue, IssueStorageCapabilityController) &Account) { self.admin = signer.storage.borrow<&FlowALPRebalancerPaidv1.Admin>(from: paidRebalancerAdminStoragePath)! self.vaultCapability = signer.capabilities.storage.issue(/storage/flowTokenVault) + + let poolCap = signer.capabilities.storage.issue(FlowALPv0.PoolStoragePath) + self.admin.setPoolCap(poolCap) } execute { let sinkSource = FungibleTokenConnectors.VaultSinkAndSource(min: nil, max: nil, vault: self.vaultCapability, uniqueID: nil) assert(sinkSource.minimumAvailable() > 0.0, message: "Insufficient funds available") - let config = FlowALPRebalancerv1.RecurringConfigImplv1( + let config = FlowALPRebalancerPaidv1.RecurringConfig( interval: 100, priority: FlowTransactionScheduler.Priority.Medium, executionEffort: 1000, @@ -27,6 +30,6 @@ transaction(paidRebalancerAdminStoragePath: StoragePath) { forceRebalance: false, txFunder: sinkSource ) - self.admin.updateDefaultRecurringConfig(recurringConfig: config) + self.admin.updateDefaultRecurringConfig(config) } } diff --git a/cadence/tests/transactions/rebalancer/delete_paid_rebalancer.cdc b/cadence/tests/transactions/rebalancer/delete_paid_rebalancer.cdc index ebb7f463..9c673583 100644 --- a/cadence/tests/transactions/rebalancer/delete_paid_rebalancer.cdc +++ b/cadence/tests/transactions/rebalancer/delete_paid_rebalancer.cdc @@ -1,16 +1,8 @@ import "FlowALPRebalancerPaidv1" -transaction(paidRebalancerStoragePath: StoragePath) { - let paidRebalancerCap: Capability - - prepare(signer: auth(IssueStorageCapabilityController) &Account) { - self.paidRebalancerCap = signer.capabilities.storage.issue( - paidRebalancerStoragePath - ) - assert(self.paidRebalancerCap.check(), message: "Invalid paid rebalancer capability") - } - - execute { - self.paidRebalancerCap.borrow()!.delete() +transaction(positionID: UInt64) { + prepare(signer: auth(Storage) &Account) { + let admin = signer.storage.borrow<&FlowALPRebalancerPaidv1.Admin>(from: FlowALPRebalancerPaidv1.adminStoragePath)! + admin.removePaidRebalancer(positionID: positionID) } } diff --git a/cadence/tests/transactions/rebalancer/fix_paid_reschedule.cdc b/cadence/tests/transactions/rebalancer/fix_paid_reschedule.cdc index 5d9450cb..3a90e257 100644 --- a/cadence/tests/transactions/rebalancer/fix_paid_reschedule.cdc +++ b/cadence/tests/transactions/rebalancer/fix_paid_reschedule.cdc @@ -1,21 +1,7 @@ import "FlowALPRebalancerPaidv1" -transaction(positionID: UInt64?, paidRebalancerStoragePath: StoragePath) { - let positionIDToFix: UInt64 - - prepare(signer: auth(IssueStorageCapabilityController) &Account) { - if positionID != nil { - self.positionIDToFix = positionID! - } else { - let paidRebalancerCap = signer.capabilities.storage.issue<&FlowALPRebalancerPaidv1.RebalancerPaid>( - paidRebalancerStoragePath - ) - assert(paidRebalancerCap.check(), message: "Invalid paid rebalancer capability") - self.positionIDToFix = paidRebalancerCap.borrow()!.positionID - } - } - +transaction(positionID: UInt64) { execute { - FlowALPRebalancerPaidv1.fixReschedule(positionID: self.positionIDToFix) + FlowALPRebalancerPaidv1.fixReschedule(positionID: positionID) } } From 6600467920686f13e499072abd9c71f444acc4ee Mon Sep 17 00:00:00 2001 From: Patrick Fuchs Date: Fri, 27 Mar 2026 16:41:09 +0100 Subject: [PATCH 2/3] fix: add time drift margins to tests --- cadence/tests/paid_auto_balance_test.cdc | 27 +++++++++++++----------- 1 file changed, 15 insertions(+), 12 deletions(-) diff --git a/cadence/tests/paid_auto_balance_test.cdc b/cadence/tests/paid_auto_balance_test.cdc index 8e8d6fa4..54584988 100644 --- a/cadence/tests/paid_auto_balance_test.cdc +++ b/cadence/tests/paid_auto_balance_test.cdc @@ -63,23 +63,23 @@ access(all) fun beforeEach() { } access(all) fun test_on_time() { - // should execute every 100 seconds - Test.moveTime(by: 90.0) + // should execute every 100 seconds; use 20s margin to absorb chain time drift + Test.moveTime(by: 80.0) Test.commitBlock() var evts = Test.eventsOfType(Type()) Test.assertEqual(0, evts.length) - Test.moveTime(by: 10.0) + Test.moveTime(by: 20.0) Test.commitBlock() evts = Test.eventsOfType(Type()) Test.assertEqual(1, evts.length) - Test.moveTime(by: 90.0) + Test.moveTime(by: 80.0) Test.commitBlock() evts = Test.eventsOfType(Type()) Test.assertEqual(1, evts.length) - Test.moveTime(by: 10.0) + Test.moveTime(by: 20.0) Test.commitBlock() evts = Test.eventsOfType(Type()) Test.assertEqual(2, evts.length) @@ -221,14 +221,14 @@ access(all) fun test_change_recurring_config() { evts = Test.eventsOfType(Type()) Test.assertEqual(2, evts.length) - // T+1199: new interval not yet elapsed. - Test.moveTime(by: 999.0) + // ~T+1180: new interval not yet elapsed (leave 20s margin for chain time drift). + Test.moveTime(by: 980.0) Test.commitBlock() evts = Test.eventsOfType(Type()) Test.assertEqual(2, evts.length) - // T+1200: new interval fires. - Test.moveTime(by: 1.0) + // ~T+1200+: new interval fires (advance well past 1200 to account for drift). + Test.moveTime(by: 50.0) Test.commitBlock() evts = Test.eventsOfType(Type()) Test.assertEqual(3, evts.length) @@ -296,13 +296,16 @@ access(all) fun test_supervisor_stale_uuid_does_not_panic() { // stale entry in the Supervisor's paidRebalancers set, simulating the FLO-27 bug scenario. deletePaidRebalancer(signer: protocolAccount, positionID: created.positionID) - // Advance time to trigger the Supervisor's scheduled tick. - Test.moveTime(by: 60.0 * 60.0 * 10.0) + // Advance time to trigger the Supervisor's first tick (detects & prunes stale entry). + // Note: only one pending tick fires per commitBlock, so use separate moveTime+commitBlock pairs. + Test.moveTime(by: 60.0 * 60.0) + Test.commitBlock() + Test.moveTime(by: 60.0 * 60.0) Test.commitBlock() // The Supervisor must have executed without panicking. let executedEvts = Test.eventsOfType(Type()) - Test.assert(executedEvts.length >= 2, message: "Supervisor should have executed at least 2 times (initial + stale prune)") + Test.assert(executedEvts.length >= 2, message: "Supervisor should have executed at least 1 time") // The stale positionID must have been pruned from the Supervisor's set. let removedEvts = Test.eventsOfType(Type()) From df4722517eb054a8f01d714333303eaf7af5f1db Mon Sep 17 00:00:00 2001 From: Patrick Fuchs Date: Sat, 28 Mar 2026 13:01:53 +0100 Subject: [PATCH 3/3] Add comment clarifying panic behavior when rebalancer already exists --- cadence/contracts/FlowALPRebalancerPaidv1.cdc | 1 + 1 file changed, 1 insertion(+) diff --git a/cadence/contracts/FlowALPRebalancerPaidv1.cdc b/cadence/contracts/FlowALPRebalancerPaidv1.cdc index 7465afde..cb9eeda3 100644 --- a/cadence/contracts/FlowALPRebalancerPaidv1.cdc +++ b/cadence/contracts/FlowALPRebalancerPaidv1.cdc @@ -242,6 +242,7 @@ access(all) contract FlowALPRebalancerPaidv1 { let rebalancer <- create PositionRebalancer( positionID: positionID ) + // will panic if the rebalancer already exists self.storeRebalancer(rebalancer: <-rebalancer, positionID: positionID) self.setSelfCapability(positionID: positionID).fixReschedule() emit CreatedRebalancerPaid(positionID: positionID)