diff --git a/cadence/contracts/FlowALPv0.cdc b/cadence/contracts/FlowALPv0.cdc index 62133be4..68905c2b 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) @@ -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, @@ -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) { @@ -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) diff --git a/cadence/tests/rebalance_undercollateralised_test.cdc b/cadence/tests/rebalance_undercollateralised_test.cdc index 8063ea3d..960b1cef 100644 --- a/cadence/tests/rebalance_undercollateralised_test.cdc +++ b/cadence/tests/rebalance_undercollateralised_test.cdc @@ -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") +}