diff --git a/cadence/contracts/FlowALPModels.cdc b/cadence/contracts/FlowALPModels.cdc index a784b6a2..fb9d33a5 100644 --- a/cadence/contracts/FlowALPModels.cdc +++ b/cadence/contracts/FlowALPModels.cdc @@ -2114,6 +2114,9 @@ access(all) contract FlowALPModels { /// Rebalances the specified position. access(EPosition | ERebalance) fun rebalancePosition(pid: UInt64, force: Bool) + + /// Queues the position for rebalance/update if its health bounds have changed. + access(EPosition) fun queuePositionForUpdateIfNecessary(pid: UInt64) } /// Factory function to create a new InternalPositionImplv1 resource. diff --git a/cadence/contracts/FlowALPPositionResources.cdc b/cadence/contracts/FlowALPPositionResources.cdc index 2255fa2e..ba406725 100644 --- a/cadence/contracts/FlowALPPositionResources.cdc +++ b/cadence/contracts/FlowALPPositionResources.cdc @@ -87,6 +87,7 @@ access(all) contract FlowALPPositionResources { let pool = FlowALPPositionResources.borrowPool() let pos = pool.borrowPosition(pid: self.id) pos.setMinHealth(UFix128(minHealth)) + pool.queuePositionForUpdateIfNecessary(pid: self.id) } /// Returns the maximum health of the Position @@ -101,6 +102,7 @@ access(all) contract FlowALPPositionResources { let pool = FlowALPPositionResources.borrowPool() 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/contracts/FlowALPv0.cdc b/cadence/contracts/FlowALPv0.cdc index d44296a9..080bf9ed 100644 --- a/cadence/contracts/FlowALPv0.cdc +++ b/cadence/contracts/FlowALPv0.cdc @@ -1987,6 +1987,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 //////////////// 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() + } +}