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
32 changes: 18 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 @@ -1928,6 +1928,10 @@ access(all) contract FlowALPv0 {
pid: pid,
from: <-pulledVault,
)

// Post-deposit health check: panic if the position is still liquidatable.
let newBalanceSheet = self._getUpdatedBalanceSheet(pid: pid)
assert(newBalanceSheet.health >= 1.0, message: "topUpSource insufficient to save position from liquidation")
}
} else if balanceSheet.health > position.getTargetHealth() {
// The position is overcollateralized,
Expand Down Expand Up @@ -2271,7 +2275,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 +2357,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
66 changes: 65 additions & 1 deletion cadence/tests/rebalance_undercollateralised_test.cdc
Original file line number Diff line number Diff line change
Expand Up @@ -109,4 +109,68 @@ fun testRebalanceUndercollateralised() {
// Ensure health is at least the minimum threshold (1.1)
Test.assert(healthAfterRebalance >= INT_MIN_HEALTH,
message: "Health after rebalance should be at least the minimum \(INT_MIN_HEALTH) but was ".concat(healthAfterRebalance.toString()))
}
}

/// Verifies that rebalancing panics when the topUpSource cannot supply enough funds to
/// bring health to ≥ 1.0. Without the fix, the protocol would deposit the insufficient
/// amount into the doomed position, trapping the user's backup funds for liquidators.
access(all)
fun testRebalanceUndercollateralised_InsufficientTopUpSource() {
Test.reset(to: snapshot)

let initialPrice = 1.0
setMockOraclePrice(signer: PROTOCOL_ACCOUNT, forTokenIdentifier: FLOW_TOKEN_IDENTIFIER, price: initialPrice)

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
)

let user = Test.createAccount()
setupMoetVault(user, beFailed: false)
mintFlow(to: user, amount: 1_000.0)
grantBetaPoolParticipantAccess(PROTOCOL_ACCOUNT, user)

// Open position: user deposits 1000 FLOW, receives ~615 MOET in their vault (topUpSource).
let openRes = executeTransaction(
"../transactions/flow-alp/position/create_position.cdc",
[1_000.0, FLOW_VAULT_STORAGE_PATH, true],
user
)
Test.expect(openRes, Test.beSucceeded())

// Drain nearly all MOET from the user's vault, leaving only 5.0.
// The topUpSource now holds far less than the ~215 MOET needed to restore health to 1.0
// after the price crash below.
let receiver = Test.createAccount()
setupMoetVault(receiver, beFailed: false)
let userMoetBalance = getBalance(address: user.address, vaultPublicPath: MOET.VaultPublicPath)!
transferFungibleTokens(
tokenIdentifier: MOET_TOKEN_IDENTIFIER,
from: user,
to: receiver,
amount: userMoetBalance - 5.0
)

// Crash the price by 50% so health falls well below 1.0.
// Effective collateral: 1000 * 0.5 * 0.8 = 400; debt ~615 → health ≈ 0.65.
// Restoring to health 1.0 requires ~215 MOET; the source has only 5.
setMockOraclePrice(signer: PROTOCOL_ACCOUNT, forTokenIdentifier: FLOW_TOKEN_IDENTIFIER, price: initialPrice * 0.5)

Test.assert(getPositionHealth(pid: 0, beFailed: false) < 1.0,
message: "Position should be liquidatable after price crash")

// Rebalance must panic: depositing 5 MOET cannot rescue the position.
let rebalanceRes = _executeTransaction(
"../transactions/flow-alp/pool-management/rebalance_position.cdc",
[ 0 as UInt64, true ],
PROTOCOL_ACCOUNT
)
Test.expect(rebalanceRes, Test.beFailed())
Test.assertError(rebalanceRes, errorMessage: "topUpSource insufficient to save position from liquidation")
}
Loading