From c900a998c1673f29db01f8d9f42796cf4ea59b19 Mon Sep 17 00:00:00 2001 From: Patrick Fuchs Date: Fri, 13 Mar 2026 11:26:03 +0100 Subject: [PATCH] queue position for async rebalance when health bounds change --- cadence/contracts/FlowALPv0.cdc | 36 +++--- ...set_health_bounds_queues_position_test.cdc | 119 ++++++++++++++++++ .../pool-management/process_update_queue.cdc | 16 +++ 3 files changed, 157 insertions(+), 14 deletions(-) create mode 100644 cadence/tests/set_health_bounds_queues_position_test.cdc create mode 100644 cadence/tests/transactions/flow-alp/pool-management/process_update_queue.cdc diff --git a/cadence/contracts/FlowALPv0.cdc b/cadence/contracts/FlowALPv0.cdc index 62133be4..9c472136 100644 --- a/cadence/contracts/FlowALPv0.cdc +++ b/cadence/contracts/FlowALPv0.cdc @@ -312,7 +312,7 @@ access(all) contract FlowALPv0 { if let tokenState = self.state.getTokenState(tokenType) { return tokenState.getInsuranceRate() } - + return nil } @@ -501,7 +501,7 @@ access(all) contract FlowALPv0 { post { !self.state.isPositionLocked(pid): "Position is not unlocked" } - + self.lockPosition(pid) let positionView = self.buildPositionView(pid: pid) @@ -521,7 +521,7 @@ access(all) contract FlowALPv0 { let Pc_oracle = self.config.getPriceOracle().price(ofToken: seizeType)! // collateral price given by oracle ($/C) // Price of collateral, denominated in debt token, implied by oracle (D/C) // Oracle says: "1 unit of collateral is worth `Pcd_oracle` units of debt" - let Pcd_oracle = Pc_oracle / Pd_oracle + let Pcd_oracle = Pc_oracle / Pd_oracle // Compute the health factor which would result if we were to accept this liquidation let Ce_pre = balanceSheet.effectiveCollateral // effective collateral pre-liquidation @@ -532,7 +532,7 @@ access(all) contract FlowALPv0 { // Ce_seize = effective value of seized collateral ($) let Ce_seize = FlowALPMath.effectiveCollateral(credit: UFix128(seizeAmount), price: UFix128(Pc_oracle), collateralFactor: Fc) // De_seize = effective value of repaid debt ($) - let De_seize = FlowALPMath.effectiveDebt(debit: UFix128(repayAmount), price: UFix128(Pd_oracle), borrowFactor: Fd) + let De_seize = FlowALPMath.effectiveDebt(debit: UFix128(repayAmount), price: UFix128(Pd_oracle), borrowFactor: Fd) let Ce_post = Ce_pre - Ce_seize // position's total effective collateral after liquidation ($) let De_post = De_pre - De_seize // position's total effective debt after liquidation ($) let postHealth = FlowALPMath.healthComputation(effectiveCollateral: Ce_post, effectiveDebt: De_post) @@ -551,9 +551,9 @@ access(all) contract FlowALPv0 { message: "DEX/oracle price deviation too large. Dex price: \(Pcd_dex), Oracle price: \(Pcd_oracle)") // Execute the liquidation let seizedCollateral <- self._doLiquidation(pid: pid, repayment: <-repayment, debtType: debtType, seizeType: seizeType, seizeAmount: seizeAmount) - + self.unlockPosition(pid) - + return <- seizedCollateral } @@ -563,7 +563,7 @@ access(all) contract FlowALPv0 { access(self) fun _doLiquidation(pid: UInt64, repayment: @{FungibleToken.Vault}, debtType: Type, seizeType: Type, seizeAmount: UFix64): @{FungibleToken.Vault} { pre { !self.isPausedOrWarmup(): "Liquidations are paused by governance" - // position must have debt and collateral balance + // position must have debt and collateral balance } let repayAmount = repayment.balance @@ -1670,7 +1670,7 @@ access(all) contract FlowALPv0 { // Validate constraint: non-zero rate requires swapper if insuranceRate > 0.0 { assert( - tsRef.getInsuranceSwapper() != nil, + tsRef.getInsuranceSwapper() != nil, message:"Cannot set non-zero insurance rate without an insurance swapper configured for \(tokenType.identifier)", ) } @@ -1689,13 +1689,13 @@ access(all) contract FlowALPv0 { self.isTokenSupported(tokenType: tokenType): "Unsupported token type" } let tsRef = self.state.borrowTokenState(tokenType) - ?? panic("Invariant: token state missing") + ?? panic("Invariant: token state missing") if let swapper = swapper { // Validate swapper types match assert(swapper.inType() == tokenType, message: "Swapper input type must match token type") assert(swapper.outType() == Type<@MOET.Vault>(), message: "Swapper output type must be MOET") - + } else { // cannot remove swapper if insurance rate > 0 assert( @@ -1779,7 +1779,7 @@ access(all) contract FlowALPv0 { let tsRef = self.state.borrowTokenState(tokenType) ?? panic("Invariant: token state missing") tsRef.setStabilityFeeRate(stabilityFeeRate) - + FlowALPEvents.emitStabilityFeeRateUpdated( poolUUID: self.uuid, tokenType: tokenType.identifier, @@ -1800,7 +1800,7 @@ access(all) contract FlowALPv0 { fundRef.balance >= amount, message: "Insufficient stability fund balance. Available: \(fundRef.balance), requested: \(amount)" ) - + let withdrawn <- fundRef.withdraw(amount: amount) recipient.deposit(from: <-withdrawn) @@ -2179,6 +2179,12 @@ access(all) contract FlowALPv0 { return <-stabilityVault } + /// Queues a position for asynchronous updates if its health is outside the configured bounds. + /// Exposed via EPosition so Position setters can trigger rebalance eligibility checks. + access(FlowALPModels.EPosition) fun queuePositionForUpdateIfNecessary(pid: UInt64) { + self._queuePositionForUpdateIfNecessary(pid: pid) + } + //////////////// // INTERNAL //////////////// @@ -2271,7 +2277,7 @@ access(all) contract FlowALPv0 { access(self) fun updateInterestRatesAndCollectInsurance(tokenType: Type) { let tokenState = self._borrowUpdatedTokenState(type: tokenType) tokenState.updateInterestRates() - + // Collect insurance if swapper is configured // Ensure reserves exist for this token type if !self.state.hasReserve(tokenType) { @@ -2353,7 +2359,7 @@ access(all) contract FlowALPv0 { access(all) fun getDefaultToken(): Type { return self.state.getDefaultToken() } - + /// Returns the deposit capacity and deposit capacity cap for a given token type access(all) fun getDepositCapacityInfo(type: Type): {String: UFix64} { let tokenState = self._borrowUpdatedTokenState(type: type) @@ -2475,6 +2481,7 @@ access(all) contract FlowALPv0 { let pool = self.pool.borrow()! let pos = pool.borrowPosition(pid: self.id) pos.setMinHealth(UFix128(minHealth)) + pool.queuePositionForUpdateIfNecessary(pid: self.id) } /// Returns the maximum health of the Position @@ -2489,6 +2496,7 @@ access(all) contract FlowALPv0 { let pool = self.pool.borrow()! let pos = pool.borrowPosition(pid: self.id) pos.setMaxHealth(UFix128(maxHealth)) + pool.queuePositionForUpdateIfNecessary(pid: self.id) } /// Returns the maximum amount of the given token type that could be deposited into this position diff --git a/cadence/tests/set_health_bounds_queues_position_test.cdc b/cadence/tests/set_health_bounds_queues_position_test.cdc new file mode 100644 index 00000000..e8fc1e29 --- /dev/null +++ b/cadence/tests/set_health_bounds_queues_position_test.cdc @@ -0,0 +1,119 @@ +import Test +import BlockchainHelpers + +import "test_helpers.cdc" + +/// Tests that setMinHealth and setMaxHealth queue the position for async update +/// when the new bounds make the current health out-of-range. +/// +/// Strategy: verify that asyncUpdate rebalances the position after the setter is called, +/// which only happens if the position was queued. Without the fix, asyncUpdate would be a no-op. +/// +/// Default health bounds: minHealth=1.1, targetHealth=1.3, maxHealth=1.5 +/// Setup: 100 FLOW collateral, collateralFactor=0.8, price=1.0 +/// effectiveCollateral = 80, debt (at targetHealth) = 80/1.3 ≈ 61.538 + +access(all) var snapshot: UInt64 = 0 + +access(all) +fun setup() { + deployContracts() + + setMockOraclePrice(signer: PROTOCOL_ACCOUNT, forTokenIdentifier: FLOW_TOKEN_IDENTIFIER, price: 1.0) + setMockOraclePrice(signer: PROTOCOL_ACCOUNT, forTokenIdentifier: MOET_TOKEN_IDENTIFIER, price: 1.0) + + createAndStorePool(signer: PROTOCOL_ACCOUNT, defaultTokenIdentifier: MOET_TOKEN_IDENTIFIER, beFailed: false) + addSupportedTokenZeroRateCurve( + signer: PROTOCOL_ACCOUNT, + tokenTypeIdentifier: FLOW_TOKEN_IDENTIFIER, + collateralFactor: 0.8, + borrowFactor: 1.0, + depositRate: 1_000_000.0, + depositCapacityCap: 1_000_000.0 + ) + + snapshot = getCurrentBlockHeight() + Test.moveTime(by: 1.0) +} + +access(all) +fun beforeEach() { + Test.reset(to: snapshot) +} + +/// Drains the async update queue so all queued positions are processed. +access(all) +fun drainQueue() { + let res = _executeTransaction( + "./transactions/flow-alp/pool-management/process_update_queue.cdc", + [], + PROTOCOL_ACCOUNT + ) + Test.expect(res, Test.beSucceeded()) +} + +/// Price of 1.1 → health ≈ 1.43, within (1.1, 1.5). +/// Setting maxHealth to 1.35 (below current health) should queue the position so that +/// asyncUpdate rebalances it back toward targetHealth (1.3). +access(all) +fun test_setMaxHealth_queues_position_when_health_exceeds_new_max() { + let user = Test.createAccount() + setupMoetVault(user, beFailed: false) + mintFlow(to: user, amount: 1_000.0) + + createPosition(admin: PROTOCOL_ACCOUNT, signer: user, amount: 100.0, vaultStoragePath: FLOW_VAULT_STORAGE_PATH, pushToDrawDownSink: true) + drainQueue() + + // Modest price increase → health ≈ 1.43, still within (1.1, 1.5) + setMockOraclePrice(signer: PROTOCOL_ACCOUNT, forTokenIdentifier: FLOW_TOKEN_IDENTIFIER, price: 1.1) + + let healthBeforeSetter = getPositionHealth(pid: 0, beFailed: false) + + // Lower maxHealth to 1.35 — current health (1.43) now exceeds the new max + let setRes = _executeTransaction( + "../transactions/flow-alp/position/set_max_health.cdc", + [0 as UInt64, 1.35 as UFix64], + user + ) + Test.expect(setRes, Test.beSucceeded()) + + // asyncUpdate should rebalance the position back toward targetHealth (1.3) + drainQueue() + + let healthAfter = getPositionHealth(pid: 0, beFailed: false) + Test.assert(healthAfter < healthBeforeSetter, + message: "Expected position to be rebalanced toward targetHealth after setMaxHealth + asyncUpdate, but health did not decrease") +} + +/// Price of 0.9 → health ≈ 1.17, within (1.1, 1.3). +/// Setting minHealth to 1.2 (above current health) should queue the position so that +/// asyncUpdate rebalances it back toward targetHealth (1.3). +access(all) +fun test_setMinHealth_queues_position_when_health_falls_below_new_min() { + let user = Test.createAccount() + setupMoetVault(user, beFailed: false) + mintFlow(to: user, amount: 1_000.0) + + createPosition(admin: PROTOCOL_ACCOUNT, signer: user, amount: 100.0, vaultStoragePath: FLOW_VAULT_STORAGE_PATH, pushToDrawDownSink: true) + drainQueue() + + // Modest price drop → health ≈ 1.17, still within (1.1, 1.3) + setMockOraclePrice(signer: PROTOCOL_ACCOUNT, forTokenIdentifier: FLOW_TOKEN_IDENTIFIER, price: 0.9) + + let healthBeforeSetter = getPositionHealth(pid: 0, beFailed: false) + + // Raise minHealth to 1.2 — current health (1.17) now falls below the new min + let setRes = _executeTransaction( + "../transactions/flow-alp/position/set_min_health.cdc", + [0 as UInt64, 1.2 as UFix64], + user + ) + Test.expect(setRes, Test.beSucceeded()) + + // asyncUpdate should rebalance the position back toward targetHealth (1.3) + drainQueue() + + let healthAfter = getPositionHealth(pid: 0, beFailed: false) + Test.assert(healthAfter > healthBeforeSetter, + message: "Expected position to be rebalanced toward targetHealth after setMinHealth + asyncUpdate, but health did not increase") +} diff --git a/cadence/tests/transactions/flow-alp/pool-management/process_update_queue.cdc b/cadence/tests/transactions/flow-alp/pool-management/process_update_queue.cdc new file mode 100644 index 00000000..67321e6f --- /dev/null +++ b/cadence/tests/transactions/flow-alp/pool-management/process_update_queue.cdc @@ -0,0 +1,16 @@ +import "FlowALPv0" +import "FlowALPModels" + +/// Drains the async update queue, processing all queued positions. +transaction { + let pool: auth(FlowALPModels.EImplementation) &FlowALPv0.Pool + + prepare(signer: auth(BorrowValue) &Account) { + self.pool = signer.storage.borrow(from: FlowALPv0.PoolStoragePath) + ?? panic("Could not borrow reference to Pool from \(FlowALPv0.PoolStoragePath) - ensure a Pool has been configured") + } + + execute { + self.pool.asyncUpdate() + } +}