Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
36 changes: 22 additions & 14 deletions cadence/contracts/FlowALPv0.cdc
Original file line number Diff line number Diff line change
Expand Up @@ -312,7 +312,7 @@ access(all) contract FlowALPv0 {
if let tokenState = self.state.getTokenState(tokenType) {
return tokenState.getInsuranceRate()
}

return nil
}

Expand Down Expand Up @@ -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)
Expand All @@ -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
Expand All @@ -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)
Expand All @@ -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
}

Expand All @@ -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
Expand Down Expand Up @@ -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)",
)
}
Expand All @@ -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(
Expand Down Expand Up @@ -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,
Expand All @@ -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)

Expand Down Expand Up @@ -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
////////////////
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down
119 changes: 119 additions & 0 deletions cadence/tests/set_health_bounds_queues_position_test.cdc
Original file line number Diff line number Diff line change
@@ -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")
}
Original file line number Diff line number Diff line change
@@ -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<auth(FlowALPModels.EImplementation) &FlowALPv0.Pool>(from: FlowALPv0.PoolStoragePath)
?? panic("Could not borrow reference to Pool from \(FlowALPv0.PoolStoragePath) - ensure a Pool has been configured")
}

execute {
self.pool.asyncUpdate()
}
}