From 2ed0198e2511021de362af13c831bd070e97c246 Mon Sep 17 00:00:00 2001 From: Jordan Schalm Date: Tue, 16 Dec 2025 18:52:23 -0800 Subject: [PATCH 01/34] add todos while reviewing existing code --- cadence/contracts/FlowCreditMarket.cdc | 23 ++++++++++++++++++----- 1 file changed, 18 insertions(+), 5 deletions(-) diff --git a/cadence/contracts/FlowCreditMarket.cdc b/cadence/contracts/FlowCreditMarket.cdc index 8fa9ff9..5d2be96 100644 --- a/cadence/contracts/FlowCreditMarket.cdc +++ b/cadence/contracts/FlowCreditMarket.cdc @@ -191,9 +191,12 @@ access(all) contract FlowCreditMarket { /// An struct containing a position's overview in terms of its effective collateral and debt as well as its /// current health access(all) struct BalanceSheet { - /// A position's withdrawable value based on collateral deposits against the Pool's collateral and borrow factors + /// A position's withdrawable value based on collateral deposits against the Pool's collateral and borrow factors. + /// TODO(jord): I think this is "withdrawable value" only if effectiveDebt==0 + /// Denominated in MOET. access(all) let effectiveCollateral: UFix128 /// A position's withdrawn value based on withdrawals against the Pool's collateral and borrow factors + /// Denominated in MOET. access(all) let effectiveDebt: UFix128 /// The health of the related position access(all) let health: UFix128 @@ -207,6 +210,7 @@ access(all) contract FlowCreditMarket { /// Liquidation parameters view (global) access(all) struct LiquidationParamsView { + /// Target health factor access(all) let targetHF: UFix128 access(all) let paused: Bool access(all) let warmupSec: UInt64 @@ -223,11 +227,17 @@ access(all) contract FlowCreditMarket { } } - /// Liquidation quote output + /// A quote generated during the liquidation process. + /// It describes the terms of liquidation for a potential liquidator. access(all) struct LiquidationQuote { + /// Amount the liquidator must repay, denominated in MOET + // TODO(jord): I think it is MOET, not sure access(all) let requiredRepay: UFix64 + /// The type of collateral the liquidator may seize in exchange for repayment. access(all) let seizeType: Type + /// The amount of collateral the liquidator may seize in exchange for repayment. access(all) let seizeAmount: UFix64 + /// The new health factor after this liquidation occurs. access(all) let newHF: UFix128 init(requiredRepay: UFix64, seizeType: Type, seizeAmount: UFix64, newHF: UFix128) { self.requiredRepay = requiredRepay @@ -471,6 +481,7 @@ access(all) contract FlowCreditMarket { self.updateInterestIndices() } + /// TODO: documentation access(all) fun updateInterestRates() { // If there's no credit balance, we can't calculate a meaningful credit rate // so we'll just set both rates to one (no interest) and return early @@ -694,6 +705,7 @@ access(all) contract FlowCreditMarket { access(self) var liquidationsPaused: Bool access(self) var liquidationWarmupSec: UInt64 access(self) var lastUnpausedAt: UInt64? + // TODO(jord): remove this access(self) var protocolLiquidationFeeBps: UInt16 /// Allowlist of permitted DeFiActions Swapper types for DEX liquidations access(self) var allowedSwapperTypes: {Type: Bool} @@ -987,7 +999,7 @@ access(all) contract FlowCreditMarket { let b = view.balances[t]! let st = self._borrowUpdatedTokenState(type: t) // Resolve per-token liquidation bonus (default 5%) for token t - var lbTUFix: UFix64 = 0.05 + var lbTUFix: UFix64 = 0.05 let lbTOpt = self.liquidationBonus[t] if lbTOpt != nil { lbTUFix = lbTOpt! @@ -1089,8 +1101,8 @@ access(all) contract FlowCreditMarket { if newHF < health { // Compute the maximum repay allowed by available seize collateral (Rcap), preserving R<->S pricing relation. // uAllowed = seizeTrue * Pc / (1 + LB) - let uAllowedMax = FlowCreditMarketMath.div(trueCollateralSeize * Pc, (FlowCreditMarketMath.one + LB)) - var repayCapBySeize = FlowCreditMarketMath.div(uAllowedMax * BF, Pd) + let uAllowedMax = FlowCreditMarketMath.div(trueCollateralSeize * Pc, (FlowCreditMarketMath.one + LB)) + var repayCapBySeize = FlowCreditMarketMath.div(uAllowedMax * BF, Pd) if repayCapBySeize > trueDebt { repayCapBySeize = trueDebt } var bestHF: UFix128 = health @@ -1157,6 +1169,7 @@ access(all) contract FlowCreditMarket { return FlowCreditMarket.LiquidationQuote(requiredRepay: repayExactBest, seizeType: seizeType, seizeAmount: seizeExactBest, newHF: bestHF) } + // TODO(jord): This is where we may disallow liquidation if it does not improve health factor // No improving pair found return FlowCreditMarket.LiquidationQuote(requiredRepay: 0.0, seizeType: seizeType, seizeAmount: 0.0, newHF: health) } From aa982a7f2b684f891112e85520017477342fb686 Mon Sep 17 00:00:00 2001 From: Jordan Schalm Date: Tue, 16 Dec 2025 19:44:20 -0800 Subject: [PATCH 02/34] more todos and documentation --- cadence/contracts/FlowCreditMarket.cdc | 41 +++++++++++++++++++++----- 1 file changed, 34 insertions(+), 7 deletions(-) diff --git a/cadence/contracts/FlowCreditMarket.cdc b/cadence/contracts/FlowCreditMarket.cdc index 5d2be96..174092e 100644 --- a/cadence/contracts/FlowCreditMarket.cdc +++ b/cadence/contracts/FlowCreditMarket.cdc @@ -516,8 +516,11 @@ access(all) contract FlowCreditMarket { /// Risk parameters for a token used in effective collateral/debt computations. - /// - collateralFactor: fraction applied to credit value to derive effective collateral - /// - borrowFactor: fraction dividing debt value to derive effective debt + /// The collateral and borrow factors are fractional values which represent a discount to the "true/market" value of the token. + /// The size of this discount indicates a subjective assessment of risk for the token. + /// The difference between the effective value and "true" value represents the safety buffer available to prevent loss. + /// - collateralFactor: the factor used to derive effective collateral + /// - borrowFactor: the factor used to derive effective debt /// - liquidationBonus: premium applied to liquidations to incentivize repayors access(all) struct RiskParams { access(all) let collateralFactor: UFix128 @@ -525,6 +528,10 @@ access(all) contract FlowCreditMarket { access(all) let liquidationBonus: UFix128 // bonus expressed as fractional rate, e.g. 0.05 for 5% init(cf: UFix128, bf: UFix128, lb: UFix128) { + pre { + cf <= FlowCreditMarketMath.one: "collateral factor must be <=1" + bf <= FlowCreditMarketMath.one: "borrow factor must be <=1" + } self.collateralFactor = cf self.borrowFactor = bf self.liquidationBonus = lb @@ -701,6 +708,7 @@ access(all) contract FlowCreditMarket { /// to detect when the interest indices need to be updated in InternalPositions. access(EImplementation) var version: UInt64 /// Liquidation target health and controls (global) + /// TODO(jord): This is the desired health factor following liquidation? access(self) var liquidationTargetHF: UFix128 // e24 fixed-point, e.g., 1.05e24 access(self) var liquidationsPaused: Bool access(self) var liquidationWarmupSec: UInt64 @@ -882,8 +890,8 @@ access(all) contract FlowCreditMarket { let trueBalance = FlowCreditMarket.scaledBalanceToTrueBalance(balance.scaledBalance, interestIndex: tokenState.creditInterestIndex) - let value = price * trueBalance - let effectiveCollateralValue = value * collateralFactor + let value = price * trueBalance + let effectiveCollateralValue = value * collateralFactor effectiveCollateral = effectiveCollateral + effectiveCollateralValue } else { let trueBalance = FlowCreditMarket.scaledBalanceToTrueBalance(balance.scaledBalance, @@ -958,10 +966,10 @@ access(all) contract FlowCreditMarket { let debtState = self._borrowUpdatedTokenState(type: debtType) let seizeState = self._borrowUpdatedTokenState(type: seizeType) // Resolve per-token liquidation bonus (default 5%) for debtType + // TODO(jord): should this be a config? var lbDebtUFix: UFix64 = 0.05 - let lbDebtOpt = self.liquidationBonus[debtType] - if lbDebtOpt != nil { - lbDebtUFix = lbDebtOpt! + if let lbDebtOpt = self.liquidationBonus[debtType] { + lbDebtUFix = lbDebtOpt } let debtSnap = FlowCreditMarket.TokenSnapshot( price: FlowCreditMarketMath.toUFix128(self.priceOracle.price(ofToken: debtType)!), @@ -1036,6 +1044,11 @@ access(all) contract FlowCreditMarket { } let requiredEffColl = effDebt * target if effColl >= requiredEffColl { + // TODO(jord): I think this is an unexpected path? + // - liquidationTargetHF must be >1, otherwise we would liquidate positions into still-unhealthy states + // - we check that health<1 at the top of this function + // - effColl >= requiredEffColl iff heath>=1 + // - therefore this branch should never execute return FlowCreditMarket.LiquidationQuote(requiredRepay: 0.0, seizeType: seizeType, seizeAmount: 0.0, newHF: health) } let deltaEffColl = requiredEffColl - effColl @@ -1044,8 +1057,22 @@ access(all) contract FlowCreditMarket { // effDebtNew = effDebt - (repayTrue * debtSnap.price / debtSnap.risk.borrowFactor) // target = effColl / effDebtNew => effDebtNew = effColl / target // So reductionNeeded = effDebt - effColl/target + // + // TODO(jord): + // This equation doesn't make sense to me: + // target = effColl / effDebtNew + // + // - we are trying to determine what final effective debt amount is necessary to bring health factor to target + // - the equation is using effColl, which is the effective collateral BEFORE the liquidation occurs + // alongside effDebtNew, which is the effective debt AFTER the liquidation occurs + // - however, the liquidation involves two things: + // - liquidator provides MOET + // - liquidator seizes collateral + // - this implies that effColl will decrease during the liquidation + // - therefore the equation does not represent a health factor at a specific point in time? let effDebtNew = FlowCreditMarketMath.div(effColl, target) if effDebt <= effDebtNew { + // TODO(jord): I think passing in target as newHF here is wrong (although probably not dangerous) return FlowCreditMarket.LiquidationQuote(requiredRepay: 0.0, seizeType: seizeType, seizeAmount: 0.0, newHF: target) } // Use simultaneous solve below; the approximate path is omitted From 1821da4ddcb68f6275c061087b77fef2e96892bf Mon Sep 17 00:00:00 2001 From: Jordan Schalm Date: Fri, 19 Dec 2025 11:03:17 -0800 Subject: [PATCH 03/34] skeleton of manual liquidation --- cadence/contracts/FlowCreditMarket.cdc | 88 ++++++++++++++++++++++++-- 1 file changed, 83 insertions(+), 5 deletions(-) diff --git a/cadence/contracts/FlowCreditMarket.cdc b/cadence/contracts/FlowCreditMarket.cdc index 174092e..ce29954 100644 --- a/cadence/contracts/FlowCreditMarket.cdc +++ b/cadence/contracts/FlowCreditMarket.cdc @@ -258,6 +258,7 @@ access(all) contract FlowCreditMarket { /// An internal resource used to track deposits, withdrawals, balances, and queued deposits to an open position. access(all) resource InternalPosition { /// The target health of the position + /// TODO(jord): Is this user-defined or protocol-defined? access(EImplementation) var targetHealth: UFix128 /// The minimum health of the position, below which a position is considered undercollateralized access(EImplementation) var minHealth: UFix128 @@ -356,8 +357,10 @@ access(all) contract FlowCreditMarket { access(all) var debitInterestIndex: UFix128 /// The interest rate for credit of the associated token, stored as UFix128 to match index precision and avoid /// cumulative rounding during compounding. + // TODO: format: APR? In call to compoundInterestIndex, assumes this is per-second rate? access(all) var currentCreditRate: UFix128 /// The interest rate for debit of the associated token. Also UFix128 for consistency with indices/rates math. + // TODO: format: APR? access(all) var currentDebitRate: UFix128 /// The interest curve implementation used to calculate interest rate access(all) var interestCurve: {InterestCurve} @@ -376,8 +379,8 @@ access(all) contract FlowCreditMarket { self.lastUpdate = getCurrentBlock().timestamp self.totalCreditBalance = 0.0 as UFix128 self.totalDebitBalance = 0.0 as UFix128 - self.creditInterestIndex = FlowCreditMarketMath.one - self.debitInterestIndex = FlowCreditMarketMath.one + self.creditInterestIndex = FlowCreditMarketMath.one // TODO: hard-coded to 1? + self.debitInterestIndex = FlowCreditMarketMath.one // TODO: hard-coded to 1? self.currentCreditRate = FlowCreditMarketMath.one self.currentDebitRate = FlowCreditMarketMath.one self.interestCurve = interestCurve @@ -572,6 +575,13 @@ access(all) contract FlowCreditMarket { } } + /// A wrapper around one or more DEXes. + access(all) struct interface SwapperProvider { + /// Returns a Swapper for the given trade pair, if the pair is supported. + /// Otherwise returns nil. + access(all) fun getSwapper(inType: Type, outType: Type): {DefiActions.Swapper}? + } + // PURE HELPERS ------------------------------------------------------------- access(all) view fun effectiveCollateral(credit: UFix128, snap: TokenSnapshot): UFix128 { @@ -699,6 +709,7 @@ access(all) contract FlowCreditMarket { /// percentage between 0.0 and 1.0 access(self) var borrowFactor: {Type: UFix64} /// Per-token liquidation bonus fraction (e.g., 0.05 for 5%) + /// TODO(jord): we want to keep this logic but set it to 0 initially access(self) var liquidationBonus: {Type: UFix64} /// The count of positions to update per asynchronous update access(self) var positionsProcessedPerCallback: UInt64 @@ -716,15 +727,19 @@ access(all) contract FlowCreditMarket { // TODO(jord): remove this access(self) var protocolLiquidationFeeBps: UInt16 /// Allowlist of permitted DeFiActions Swapper types for DEX liquidations + // TODO(jord): currently we store an allow-list of swapper types, but access(self) var allowedSwapperTypes: {Type: Bool} + access(self) var dex: {SwapperProvider} /// Max allowed deviation in basis points between DEX-implied price and oracle price access(self) var dexOracleDeviationBps: UInt16 /// Max slippage allowed in basis points for DEX liquidations + /// TODO(jord): revisit this. Is this ever necessary if we are also checking dexOracleDeviationBps? Do we want both a spot price check and a slippage from spot price check? access(self) var dexMaxSlippageBps: UInt64 /// Max route hops allowed for DEX liquidations + // TODO(jord): unused access(self) var dexMaxRouteHops: UInt64 - init(defaultToken: Type, priceOracle: {DeFiActions.PriceOracle}) { + init(defaultToken: Type, priceOracle: {DeFiActions.PriceOracle}, dex: {SwapperProvider}) { pre { priceOracle.unitOfAccount() == defaultToken: "Price oracle must return prices in terms of the default token" } @@ -752,6 +767,7 @@ access(all) contract FlowCreditMarket { self.lastUnpausedAt = nil self.protocolLiquidationFeeBps = UInt16(0) self.allowedSwapperTypes = {} + self.dex = dex self.dexOracleDeviationBps = UInt16(300) // 3% default self.dexMaxSlippageBps = 100 self.dexMaxRouteHops = 3 @@ -872,6 +888,7 @@ access(all) contract FlowCreditMarket { /// Returns the health of the given position, which is the ratio of the position's effective collateral to its /// debt as denominated in the Pool's default token. "Effective collateral" means the value of each credit balance /// times the liquidation threshold for that token. i.e. the maximum borrowable amount + // TODO: make this output enumeration of effective debts/collaterals (or provide option that does) access(all) fun positionHealth(pid: UInt64): UFix128 { let position = self._borrowPosition(pid: pid) @@ -951,6 +968,60 @@ access(all) contract FlowCreditMarket { ) } + + /// Any external party can perform a manual liquidation on a position P under the following circumstances: + /// - P has health < 1 + /// - the liquidation price offered is better than what is available on a DEX + access(all) fun manualLiquidation( + pid: UInt64, + debtType: Type, + repayAmount: UFix64, + seizeType: Type, + seizeAmount: UFix64, + repaymentSource: @{FungibleToken.Vault} + ) { + pre { + // debt, collateral are both supported tokens + // repaymentSource has sufficient balance + } + post { + // health factor should be <= target + } + + let positionView = self.buildPositionView(pid: pid) + let health = FlowCreditMarket.healthFactor(view: positionView) + destroy repaymentSource // maybe remove this + if health >= 1.0 { + return + } + + let swapper = self.dex.getSwapper(inType: seizeType, outType: debtType)! // will revert if pair unsupported + // This asks "how much collateral do I need to give you to get repayAmount debt tokens" + let quote = swapper.quoteIn(forDesired: repayAmount, reverse: false) + // If the DEX would provide more debt tokens for the same amount of collateral, then reject the liquidation offer. + if (quote.inAmount < seizeAmount) { + return + } + + // At this point, the liquidation offer appears acceptable. As a sanity check, compare the DEX price to the oracle price. + let Pd_oracle = self.priceOracle.price(ofToken: debtType)! // $/D + let Pc_oracle = self.priceOracle.price(ofToken: seizeType)! // $/C + let Pcd_oracle = Pd_oracle / Pc_oracle // C/D - price of collateral, denominated in debt token, implied by oracle + + let Pcd_dex = quote.inAmount / quote.outAmount // C/D - price of collateral, denominated in debt token, implied by dex quote + let Pcd_offer = seizeAmount / repayAmount // C/D - price of collateral, denominated in debt token, implied by liquidation offer + + // Compute the absolute value of the difference between the oracle price and dex price + let Pcd_dex_oracle_diff: UFix64 = Pcd_dex < Pcd_oracle ? Pcd_oracle - Pcd_dex : Pcd_dex - Pcd_oracle + // Compute the percent difference (eg. 0.05 for 5%). For consistency, we always use the larger price as the denominator. + let Pcd_dex_oracle_diffPct: UFix64 = Pcd_dex < Pcd_oracle ? Pcd_dex_oracle_diff / Pcd_dex : Pcd_dex_oracle_diff / Pcd_oracle + let Pcd_dex_oracle_diffBps = UInt16(Pcd_dex_oracle_diffPct * 10_000.0) + + assert(Pcd_dex_oracle_diffBps > self.dexOracleDeviationBps, message: "Too large difference between dex/oracle prices diff=\(Pcd_dex_oracle_diffBps)bps") + + // perform the liquidation at this point + } + /// Quote liquidation required repay and seize amounts to bring HF to liquidationTargetHF using a single seizeType access(all) fun quoteLiquidation(pid: UInt64, debtType: Type, seizeType: Type): FlowCreditMarket.LiquidationQuote { pre { @@ -1001,7 +1072,7 @@ access(all) contract FlowCreditMarket { // Recompute effective totals and capture available true collateral for seizeType var effColl: UFix128 = 0.0 as UFix128 var effDebt: UFix128 = 0.0 as UFix128 - var trueCollateralSeize: UFix128 = 0.0 as UFix128 + var trueCollateralSeize: UFix128 = 0.0 as UFix128 // includes accrued interest var trueDebt: UFix128 = 0.0 as UFix128 for t in view.balances.keys { let b = view.balances[t]! @@ -1094,12 +1165,17 @@ access(all) contract FlowCreditMarket { } // Derived formula with positive denominator: u = (t * effDebt - effColl) / (t - (1 + LB) * CF) + // TODO: What is u? + // num = (t * effDebt - effColl) let num = effDebt * target - effColl + // denomFactor = (t - (1 + LB) * CF) let denomFactor = target - ((FlowCreditMarketMath.one + LB) * CF) if denomFactor <= FlowCreditMarketMath.zero { // Impossible target, return 0 return FlowCreditMarket.LiquidationQuote(requiredRepay: 0.0, seizeType: seizeType, seizeAmount: 0.0, newHF: health) } + // repayTrueU128 = (num * BF) / (Pd * denomFactor) + // repayTrueU128 = [(t * effDebt - effColl)(BF)] / [(Pd)(t - (1+LB)*CF))] var repayTrueU128 = FlowCreditMarketMath.div(num * BF, Pd * denomFactor) if repayTrueU128 > trueDebt { repayTrueU128 = trueDebt @@ -1239,6 +1315,7 @@ access(all) contract FlowCreditMarket { } /// Permissionless liquidation: keeper repays exactly the required amount to reach target HF and receives seized collateral + // This uses liquidationQuote, which is "maybe AI slop no one understands" access(all) fun liquidateRepayForSeize( pid: UInt64, debtType: Type, @@ -1329,7 +1406,7 @@ access(all) contract FlowCreditMarket { // Validate position is liquidatable let health = self.positionHealth(pid: pid) assert(health < FlowCreditMarketMath.one, message: "Position not liquidatable") - assert(self.isLiquidatable(pid: pid), message: "Position \(pid) is not liquidatable") + assert(self.isLiquidatable(pid: pid), message: "Position \(pid) is not liquidatable") // TODO(jord): this is equivalent to above two lines // Internal quote to determine required seize (capped by max) let internalQuote = self.quoteLiquidation(pid: pid, debtType: debtType, seizeType: seizeType) @@ -2994,6 +3071,7 @@ access(all) contract FlowCreditMarket { let factory = self.account.storage.borrow<&PoolFactory>(from: self.PoolFactoryPath)! } + /// TODO(jord): document or remove + move to where other resources are defined access(all) resource LiquidationResult: Burner.Burnable { access(all) var seized: @{FungibleToken.Vault}? access(all) var remainder: @{FungibleToken.Vault}? From 5b53c1f8f0aa47515d51e698eb8653b14aa13a5d Mon Sep 17 00:00:00 2001 From: Jordan Schalm Date: Fri, 19 Dec 2025 14:17:07 -0800 Subject: [PATCH 04/34] add health check: must be <=target after liquidation --- cadence/contracts/FlowCreditMarket.cdc | 86 ++++++++++++++++++++++---- 1 file changed, 75 insertions(+), 11 deletions(-) diff --git a/cadence/contracts/FlowCreditMarket.cdc b/cadence/contracts/FlowCreditMarket.cdc index ce29954..d4e62de 100644 --- a/cadence/contracts/FlowCreditMarket.cdc +++ b/cadence/contracts/FlowCreditMarket.cdc @@ -579,7 +579,7 @@ access(all) contract FlowCreditMarket { access(all) struct interface SwapperProvider { /// Returns a Swapper for the given trade pair, if the pair is supported. /// Otherwise returns nil. - access(all) fun getSwapper(inType: Type, outType: Type): {DefiActions.Swapper}? + access(all) fun getSwapper(inType: Type, outType: Type): {DeFiActions.Swapper}? } // PURE HELPERS ------------------------------------------------------------- @@ -593,6 +593,7 @@ access(all) contract FlowCreditMarket { } /// Computes health = totalEffectiveCollateral / totalEffectiveDebt (∞ when debt == 0) + // TODO: return BalanceSheet, this seems like a dupe of _getUpdatedBalanceSheet access(all) view fun healthFactor(view: PositionView): UFix128 { var effectiveCollateralTotal: UFix128 = 0.0 as UFix128 var effectiveDebtTotal: UFix128 = 0.0 as UFix128 @@ -631,6 +632,7 @@ access(all) contract FlowCreditMarket { return 0.0 as UFix128 } + // TODO(jord): this logic duplicates BalanceSheet construction var effectiveCollateralTotal: UFix128 = 0.0 as UFix128 var effectiveDebtTotal: UFix128 = 0.0 as UFix128 for tokenType in view.balances.keys { @@ -968,14 +970,17 @@ access(all) contract FlowCreditMarket { ) } - /// Any external party can perform a manual liquidation on a position P under the following circumstances: /// - P has health < 1 /// - the liquidation price offered is better than what is available on a DEX + // + // Test cases: + // - proposal brings health above target (not allowed) + // - proposal reduces health (allowed) access(all) fun manualLiquidation( pid: UInt64, debtType: Type, - repayAmount: UFix64, + repayAmount: UFix64, // TODO does it need to be ufix64 on api boundary? seizeType: Type, seizeAmount: UFix64, repaymentSource: @{FungibleToken.Vault} @@ -989,14 +994,32 @@ access(all) contract FlowCreditMarket { } let positionView = self.buildPositionView(pid: pid) - let health = FlowCreditMarket.healthFactor(view: positionView) - destroy repaymentSource // maybe remove this - if health >= 1.0 { + let balanceSheet = self._getUpdatedBalanceSheet(pid: pid) + let initialHealth = balanceSheet.health + destroy repaymentSource // TODO remove this + if initialHealth >= 1.0 { return } + let Pd_oracle = self.priceOracle.price(ofToken: debtType)! // $/D + let Pc_oracle = self.priceOracle.price(ofToken: seizeType)! // $/C + let Pcd_oracle = Pd_oracle / Pc_oracle // C/D - price of collateral, denominated in debt token, implied by oracle + + // Compute the health factor which would result if we were to accept this liquidation + let Ce_pre = balanceSheet.effectiveCollateral + let De_pre = balanceSheet.effectiveDebt + let Fc = positionView.snapshots[seizeType]!.risk.collateralFactor + let Fd = positionView.snapshots[debtType]!.risk.collateralFactor + + let Ce_seize = UFix128(seizeAmount) * UFix128(Pc_oracle) * Fc + let De_seize = UFix128(repayAmount) * UFix128(Pd_oracle) * Fd + let Ce_post = Ce_pre - Ce_seize + let De_post = De_pre - De_seize + let postHealth = FlowCreditMarket.healthComputation(effectiveCollateral: Ce_post, effectiveDebt: De_post) + assert(postHealth <= self.liquidationTargetHF, message: "Liquidation must not exceed target health: \(postHealth)>\(self.liquidationTargetHF)") + let swapper = self.dex.getSwapper(inType: seizeType, outType: debtType)! // will revert if pair unsupported - // This asks "how much collateral do I need to give you to get repayAmount debt tokens" + // Quote: "how much collateral do I need to give you to get `repayAmount` debt tokens" let quote = swapper.quoteIn(forDesired: repayAmount, reverse: false) // If the DEX would provide more debt tokens for the same amount of collateral, then reject the liquidation offer. if (quote.inAmount < seizeAmount) { @@ -1004,22 +1027,62 @@ access(all) contract FlowCreditMarket { } // At this point, the liquidation offer appears acceptable. As a sanity check, compare the DEX price to the oracle price. - let Pd_oracle = self.priceOracle.price(ofToken: debtType)! // $/D - let Pc_oracle = self.priceOracle.price(ofToken: seizeType)! // $/C - let Pcd_oracle = Pd_oracle / Pc_oracle // C/D - price of collateral, denominated in debt token, implied by oracle + let Pcd_dex = quote.inAmount / quote.outAmount // C/D - price of collateral, denominated in debt token, implied by dex quote let Pcd_offer = seizeAmount / repayAmount // C/D - price of collateral, denominated in debt token, implied by liquidation offer // Compute the absolute value of the difference between the oracle price and dex price let Pcd_dex_oracle_diff: UFix64 = Pcd_dex < Pcd_oracle ? Pcd_oracle - Pcd_dex : Pcd_dex - Pcd_oracle - // Compute the percent difference (eg. 0.05 for 5%). For consistency, we always use the larger price as the denominator. + // Compute the percent difference (eg. 0.05 for 5%). Always use the larger price as the denominator. + // TODO smaller is more conservative? does it matter? let Pcd_dex_oracle_diffPct: UFix64 = Pcd_dex < Pcd_oracle ? Pcd_dex_oracle_diff / Pcd_dex : Pcd_dex_oracle_diff / Pcd_oracle let Pcd_dex_oracle_diffBps = UInt16(Pcd_dex_oracle_diffPct * 10_000.0) assert(Pcd_dex_oracle_diffBps > self.dexOracleDeviationBps, message: "Too large difference between dex/oracle prices diff=\(Pcd_dex_oracle_diffBps)bps") + // TODO: make sure we aren't bringing health above target + // perform the liquidation at this point + + } + + // TODO(jord) : Copied section from existing liquidation function -- pull out any useful + access(self) fun doLiquidation() { + assert(from.getType() == debtType, message: "Vault type mismatch for repay") + assert(from.balance >= quote.requiredRepay, message: "Repay vault balance must be at least requiredRepay") + let toUse <- from.withdraw(amount: quote.requiredRepay) + let debtReserveRef = (&self.reserves[debtType] as auth(FungibleToken.Withdraw) &{FungibleToken.Vault}?)! + debtReserveRef.deposit(from: <-toUse) + + // Reduce borrower's debt position by repayAmount + let position = self._borrowPosition(pid: pid) + let debtState = self._borrowUpdatedTokenState(type: debtType) + let repayUint = FlowCreditMarketMath.toUFix128(quote.requiredRepay) + if position.balances[debtType] == nil { + position.balances[debtType] = InternalBalance(direction: BalanceDirection.Debit, scaledBalance: 0.0 as UFix128) + } + position.balances[debtType]!.recordDeposit(amount: repayUint, tokenState: debtState) + + // Withdraw seized collateral from position and send to liquidator + let seizeState = self._borrowUpdatedTokenState(type: seizeType) + let seizeUint = FlowCreditMarketMath.toUFix128(quote.seizeAmount) + if position.balances[seizeType] == nil { + position.balances[seizeType] = InternalBalance(direction: BalanceDirection.Credit, scaledBalance: 0.0 as UFix128) + } + position.balances[seizeType]!.recordWithdrawal(amount: seizeUint, tokenState: seizeState) + let seizeReserveRef = (&self.reserves[seizeType] as auth(FungibleToken.Withdraw) &{FungibleToken.Vault}?)! + let payout <- seizeReserveRef.withdraw(amount: quote.seizeAmount) + + let actualNewHF = self.positionHealth(pid: pid) + // Ensure realized HF is not materially below quoted HF (allow tiny rounding tolerance) + let expectedHF = quote.newHF + let hfTolerance: UFix128 = FlowCreditMarketMath.toUFix128(0.00001) + assert(actualNewHF + hfTolerance >= expectedHF, message: "Post-liquidation HF below expected") + + emit LiquidationExecuted(pid: pid, poolUUID: self.uuid, debtType: debtType.identifier, repayAmount: quote.requiredRepay, seizeType: seizeType.identifier, seizeAmount: quote.seizeAmount, newHF: actualNewHF) + + return <- create LiquidationResult(seized: <-payout, remainder: <-from) } /// Quote liquidation required repay and seize amounts to bring HF to liquidationTargetHF using a single seizeType @@ -2547,6 +2610,7 @@ access(all) contract FlowCreditMarket { } /// Returns a position's BalanceSheet containing its effective collateral and debt as well as its current health + /// TODO(jord): in all cases callers already are calling _borrowPosition, more efficient to pass in PositionView? access(self) fun _getUpdatedBalanceSheet(pid: UInt64): BalanceSheet { let position = self._borrowPosition(pid: pid) let priceOracle = &self.priceOracle as &{DeFiActions.PriceOracle} From 9457d0de1ef092c2cb9b939cfba277065950ba93 Mon Sep 17 00:00:00 2001 From: Jordan Schalm Date: Fri, 19 Dec 2025 16:30:17 -0800 Subject: [PATCH 05/34] implement internal liquidation function + bound checks on seize/repay assets --- cadence/contracts/FlowCreditMarket.cdc | 126 ++++++++++++++----------- 1 file changed, 71 insertions(+), 55 deletions(-) diff --git a/cadence/contracts/FlowCreditMarket.cdc b/cadence/contracts/FlowCreditMarket.cdc index d4e62de..6fb1944 100644 --- a/cadence/contracts/FlowCreditMarket.cdc +++ b/cadence/contracts/FlowCreditMarket.cdc @@ -573,6 +573,16 @@ access(all) contract FlowCreditMarket { self.minHealth = min self.maxHealth = max } + + access(all) fun trueBalance(ofToken: Type): UFix128 { + let balance = self.balances[ofToken]! + let tokenSnapshot = self.snapshots[ofToken]! + let trueBalance = FlowCreditMarket.scaledBalanceToTrueBalance( + balance.scaledBalance, + interestIndex: tokenSnapshot.creditIndex + ) + return trueBalance + } } /// A wrapper around one or more DEXes. @@ -970,21 +980,31 @@ access(all) contract FlowCreditMarket { ) } - /// Any external party can perform a manual liquidation on a position P under the following circumstances: - /// - P has health < 1 - /// - the liquidation price offered is better than what is available on a DEX + /// Any external party can perform a manual liquidation on a position under the following circumstances: + /// - the position has health < 1 + /// - the liquidation price offered is better than what is available on a DEX + // - the liquidation results in a health <= liquidationTargetHF + // + // Terminology: + // - N means number of some token: Nc means number of collateral tokens, Nd means number of debt tokens + // - P means price of some token: Pc, Pd mean price of collateral, + // - C means collateral: Ce is effective collateral, Ct is true collateral, measured in $ + // - D means debt: De is effective debt, Dt is true debt, measured in $ + // - Fc, Fd are collateral and debt factors // // Test cases: // - proposal brings health above target (not allowed) // - proposal reduces health (allowed) + // - proposal pays more debt than position has + // - proposal seizes more collateral than position has access(all) fun manualLiquidation( pid: UInt64, debtType: Type, - repayAmount: UFix64, // TODO does it need to be ufix64 on api boundary? + repayAmount: UFix64, // TODO does it need to be ufix64 on public api boundary? seizeType: Type, seizeAmount: UFix64, repaymentSource: @{FungibleToken.Vault} - ) { + ): @FlowCreditMarket.LiquidationResult { pre { // debt, collateral are both supported tokens // repaymentSource has sufficient balance @@ -996,91 +1016,87 @@ access(all) contract FlowCreditMarket { let positionView = self.buildPositionView(pid: pid) let balanceSheet = self._getUpdatedBalanceSheet(pid: pid) let initialHealth = balanceSheet.health - destroy repaymentSource // TODO remove this - if initialHealth >= 1.0 { - return - } + assert(initialHealth >= 1.0, message: "Cannot liquidate healthy position: \(initialHealth)>1") - let Pd_oracle = self.priceOracle.price(ofToken: debtType)! // $/D - let Pc_oracle = self.priceOracle.price(ofToken: seizeType)! // $/C - let Pcd_oracle = Pd_oracle / Pc_oracle // C/D - price of collateral, denominated in debt token, implied by oracle + // Ensure liquidation amounts don't exceed position amounts + let Nc = positionView.trueBalance(ofToken: seizeType) // number of collateral tokens (true balance) + let Nd = positionView.trueBalance(ofToken: debtType) // number of debt tokens (true balance) + assert(UFix128(seizeAmount) <= Nc, message: "Cannot seize more collateral than is in position: \(Nc)<\(seizeAmount))") + assert(UFix128(repayAmount) <= Nd, message: "Cannot repay more debt than is in position: \(Nd)<\(repayAmount))") + + // Oracle prices + let Pd_oracle = self.priceOracle.price(ofToken: debtType)! // debt price given by oracle ($/D) + let Pc_oracle = self.priceOracle.price(ofToken: seizeType)! // collateral price given by oracle ($/C) + let Pcd_oracle = Pd_oracle / Pc_oracle // price of collateral, denominated in debt token, implied by oracle (C/D) // Compute the health factor which would result if we were to accept this liquidation - let Ce_pre = balanceSheet.effectiveCollateral - let De_pre = balanceSheet.effectiveDebt + let Ce_pre = balanceSheet.effectiveCollateral // effective collateral pre-liquidation + let De_pre = balanceSheet.effectiveDebt // effective debt pre-liquidation let Fc = positionView.snapshots[seizeType]!.risk.collateralFactor let Fd = positionView.snapshots[debtType]!.risk.collateralFactor - let Ce_seize = UFix128(seizeAmount) * UFix128(Pc_oracle) * Fc - let De_seize = UFix128(repayAmount) * UFix128(Pd_oracle) * Fd - let Ce_post = Ce_pre - Ce_seize - let De_post = De_pre - De_seize + let Ce_seize = UFix128(seizeAmount) * UFix128(Pc_oracle) * Fc // effective value of seized collateral ($) + let De_seize = UFix128(repayAmount) * UFix128(Pd_oracle) * Fd // effective value of repaid debt ($) + let Ce_post = Ce_pre - Ce_seize // total effective collateral after liquidation ($) + let De_post = De_pre - De_seize // total effective debt after liquidation ($) let postHealth = FlowCreditMarket.healthComputation(effectiveCollateral: Ce_post, effectiveDebt: De_post) assert(postHealth <= self.liquidationTargetHF, message: "Liquidation must not exceed target health: \(postHealth)>\(self.liquidationTargetHF)") - let swapper = self.dex.getSwapper(inType: seizeType, outType: debtType)! // will revert if pair unsupported - // Quote: "how much collateral do I need to give you to get `repayAmount` debt tokens" + let swapper = self.dex.getSwapper(inType: seizeType, outType: debtType)! // TODO: will revert if pair unsupported + // Get a quote: "how much collateral do I need to give you to get `repayAmount` debt tokens" let quote = swapper.quoteIn(forDesired: repayAmount, reverse: false) // If the DEX would provide more debt tokens for the same amount of collateral, then reject the liquidation offer. - if (quote.inAmount < seizeAmount) { - return - } + assert(quote.inAmount < seizeAmount, message: "Liquidation offer must be better than that offered by DEX") - // At this point, the liquidation offer appears acceptable. As a sanity check, compare the DEX price to the oracle price. - - - let Pcd_dex = quote.inAmount / quote.outAmount // C/D - price of collateral, denominated in debt token, implied by dex quote - let Pcd_offer = seizeAmount / repayAmount // C/D - price of collateral, denominated in debt token, implied by liquidation offer - + // Compare the DEX price to the oracle price and revert if they diverge beyond configured threshold. + let Pcd_dex = quote.inAmount / quote.outAmount // price of collateral, denominated in debt token, implied by dex quote (C/D) // Compute the absolute value of the difference between the oracle price and dex price let Pcd_dex_oracle_diff: UFix64 = Pcd_dex < Pcd_oracle ? Pcd_oracle - Pcd_dex : Pcd_dex - Pcd_oracle - // Compute the percent difference (eg. 0.05 for 5%). Always use the larger price as the denominator. - // TODO smaller is more conservative? does it matter? + // Compute the percent difference (eg. 0.05 for 5%). Always use the smaller price as the denominator. let Pcd_dex_oracle_diffPct: UFix64 = Pcd_dex < Pcd_oracle ? Pcd_dex_oracle_diff / Pcd_dex : Pcd_dex_oracle_diff / Pcd_oracle - let Pcd_dex_oracle_diffBps = UInt16(Pcd_dex_oracle_diffPct * 10_000.0) + let Pcd_dex_oracle_diffBps = UInt16(Pcd_dex_oracle_diffPct * 10_000.0) // cannot overflow because Pcd_dex_oracle_diffPct<=1 assert(Pcd_dex_oracle_diffBps > self.dexOracleDeviationBps, message: "Too large difference between dex/oracle prices diff=\(Pcd_dex_oracle_diffBps)bps") - // TODO: make sure we aren't bringing health above target - - // perform the liquidation at this point - + // Execute the liquidation + return <- self._doLiquidation(pid: pid, from: <-repaymentSource, debtType: debtType, seizeType: seizeType, seizeAmount: seizeAmount) } - // TODO(jord) : Copied section from existing liquidation function -- pull out any useful - access(self) fun doLiquidation() { + // Internal liquidation function which performs a liquidation + // Callers are responsible for checking preconditions. + access(self) fun _doLiquidation(pid: UInt64, from: @{FungibleToken.Vault}, debtType: Type, seizeType: Type, seizeAmount: UFix64): @LiquidationResult { + pre { + // position must have debt and collateral balance + } + + let repayAmount = from.balance + let repayment <- from.withdraw(amount: repayAmount) assert(from.getType() == debtType, message: "Vault type mismatch for repay") - assert(from.balance >= quote.requiredRepay, message: "Repay vault balance must be at least requiredRepay") - let toUse <- from.withdraw(amount: quote.requiredRepay) let debtReserveRef = (&self.reserves[debtType] as auth(FungibleToken.Withdraw) &{FungibleToken.Vault}?)! - debtReserveRef.deposit(from: <-toUse) + debtReserveRef.deposit(from: <-repayment) // Reduce borrower's debt position by repayAmount let position = self._borrowPosition(pid: pid) let debtState = self._borrowUpdatedTokenState(type: debtType) - let repayUint = FlowCreditMarketMath.toUFix128(quote.requiredRepay) + if position.balances[debtType] == nil { - position.balances[debtType] = InternalBalance(direction: BalanceDirection.Debit, scaledBalance: 0.0 as UFix128) + position.balances[debtType] = InternalBalance(direction: BalanceDirection.Debit, scaledBalance: 0.0) } - position.balances[debtType]!.recordDeposit(amount: repayUint, tokenState: debtState) + position.balances[debtType]!.recordDeposit(amount: UFix128(repayAmount), tokenState: debtState) // Withdraw seized collateral from position and send to liquidator let seizeState = self._borrowUpdatedTokenState(type: seizeType) - let seizeUint = FlowCreditMarketMath.toUFix128(quote.seizeAmount) if position.balances[seizeType] == nil { - position.balances[seizeType] = InternalBalance(direction: BalanceDirection.Credit, scaledBalance: 0.0 as UFix128) + position.balances[seizeType] = InternalBalance(direction: BalanceDirection.Credit, scaledBalance: 0.0) } - position.balances[seizeType]!.recordWithdrawal(amount: seizeUint, tokenState: seizeState) + position.balances[seizeType]!.recordWithdrawal(amount: UFix128(seizeAmount), tokenState: seizeState) let seizeReserveRef = (&self.reserves[seizeType] as auth(FungibleToken.Withdraw) &{FungibleToken.Vault}?)! - let payout <- seizeReserveRef.withdraw(amount: quote.seizeAmount) + let payout <- seizeReserveRef.withdraw(amount: seizeAmount) - let actualNewHF = self.positionHealth(pid: pid) - // Ensure realized HF is not materially below quoted HF (allow tiny rounding tolerance) - let expectedHF = quote.newHF - let hfTolerance: UFix128 = FlowCreditMarketMath.toUFix128(0.00001) - assert(actualNewHF + hfTolerance >= expectedHF, message: "Post-liquidation HF below expected") + let newHealth = self.positionHealth(pid: pid) + // TODO: sanity check health here? for auto-liquidating, we may need to perform a bounded search which could result in unbounded error in the final health - emit LiquidationExecuted(pid: pid, poolUUID: self.uuid, debtType: debtType.identifier, repayAmount: quote.requiredRepay, seizeType: seizeType.identifier, seizeAmount: quote.seizeAmount, newHF: actualNewHF) + emit LiquidationExecuted(pid: pid, poolUUID: self.uuid, debtType: debtType.identifier, repayAmount: repayAmount, seizeType: seizeType.identifier, seizeAmount: seizeAmount, newHF: newHealth) return <- create LiquidationResult(seized: <-payout, remainder: <-from) } @@ -2661,7 +2677,7 @@ access(all) contract FlowCreditMarket { ?? panic("Invalid position ID \(pid) - could not find an InternalPosition with the requested ID in the Pool") } - /// Build a PositionView for the given position ID + /// Build a PositionView for the given position ID. access(all) fun buildPositionView(pid: UInt64): FlowCreditMarket.PositionView { let position = self._borrowPosition(pid: pid) let snaps: {Type: FlowCreditMarket.TokenSnapshot} = {} From 095e24ca4e6ead2f313a2c9f8dd2842b5993721c Mon Sep 17 00:00:00 2001 From: Jordan Schalm Date: Wed, 7 Jan 2026 09:31:19 -0800 Subject: [PATCH 06/34] fix dex/oravle inequality assertion --- cadence/contracts/FlowCreditMarket.cdc | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cadence/contracts/FlowCreditMarket.cdc b/cadence/contracts/FlowCreditMarket.cdc index cc53870..902e3a7 100644 --- a/cadence/contracts/FlowCreditMarket.cdc +++ b/cadence/contracts/FlowCreditMarket.cdc @@ -1451,7 +1451,7 @@ access(all) contract FlowCreditMarket { let Pcd_dex_oracle_diffPct: UFix64 = Pcd_dex < Pcd_oracle ? Pcd_dex_oracle_diff / Pcd_dex : Pcd_dex_oracle_diff / Pcd_oracle let Pcd_dex_oracle_diffBps = UInt16(Pcd_dex_oracle_diffPct * 10_000.0) // cannot overflow because Pcd_dex_oracle_diffPct<=1 - assert(Pcd_dex_oracle_diffBps > self.dexOracleDeviationBps, message: "Too large difference between dex/oracle prices diff=\(Pcd_dex_oracle_diffBps)bps") + assert(Pcd_dex_oracle_diffBps <= self.dexOracleDeviationBps, message: "Too large difference between dex/oracle prices diff=\(Pcd_dex_oracle_diffBps)bps") // Execute the liquidation return <- self._doLiquidation(pid: pid, from: <-repaymentSource, debtType: debtType, seizeType: seizeType, seizeAmount: seizeAmount) From 05dbf8b38a8a2981bbcdeb20efd6a7b9158f5469 Mon Sep 17 00:00:00 2001 From: Jordan Schalm Date: Wed, 7 Jan 2026 10:04:12 -0800 Subject: [PATCH 07/34] fix inconsistent calculation of implied collateral price --- cadence/contracts/FlowCreditMarket.cdc | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/cadence/contracts/FlowCreditMarket.cdc b/cadence/contracts/FlowCreditMarket.cdc index 902e3a7..a58e985 100644 --- a/cadence/contracts/FlowCreditMarket.cdc +++ b/cadence/contracts/FlowCreditMarket.cdc @@ -1422,7 +1422,9 @@ access(all) contract FlowCreditMarket { // Oracle prices let Pd_oracle = self.priceOracle.price(ofToken: debtType)! // debt price given by oracle ($/D) let Pc_oracle = self.priceOracle.price(ofToken: seizeType)! // collateral price given by oracle ($/C) - let Pcd_oracle = Pd_oracle / Pc_oracle // price of collateral, denominated in debt token, implied by oracle (C/D) + // 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 // Compute the health factor which would result if we were to accept this liquidation let Ce_pre = balanceSheet.effectiveCollateral // effective collateral pre-liquidation @@ -1432,19 +1434,22 @@ access(all) contract FlowCreditMarket { let Ce_seize = UFix128(seizeAmount) * UFix128(Pc_oracle) * Fc // effective value of seized collateral ($) let De_seize = UFix128(repayAmount) * UFix128(Pd_oracle) * Fd // effective value of repaid debt ($) - let Ce_post = Ce_pre - Ce_seize // total effective collateral after liquidation ($) - let De_post = De_pre - De_seize // total effective debt after liquidation ($) + 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 = FlowCreditMarket.healthComputation(effectiveCollateral: Ce_post, effectiveDebt: De_post) assert(postHealth <= self.liquidationTargetHF, message: "Liquidation must not exceed target health: \(postHealth)>\(self.liquidationTargetHF)") + // Compare the liquidation offer to liquidation via DEX. If the DEX would provide a better price, reject the offer. let swapper = self.dex.getSwapper(inType: seizeType, outType: debtType)! // TODO: will revert if pair unsupported // Get a quote: "how much collateral do I need to give you to get `repayAmount` debt tokens" let quote = swapper.quoteIn(forDesired: repayAmount, reverse: false) - // If the DEX would provide more debt tokens for the same amount of collateral, then reject the liquidation offer. - assert(quote.inAmount < seizeAmount, message: "Liquidation offer must be better than that offered by DEX") + assert(seizeAmount < quote.inAmount, message: "Liquidation offer must be better than that offered by DEX") // Compare the DEX price to the oracle price and revert if they diverge beyond configured threshold. - let Pcd_dex = quote.inAmount / quote.outAmount // price of collateral, denominated in debt token, implied by dex quote (C/D) + // Suppose our collateral is X and our debt is Y + // We get a quote for 10X for 100Y -> X=10Y, so Pxy = + // Then the price of collateral denominated in debt is 10$/100FLOW = 0.1$/FLOW = 0.1C/D + let Pcd_dex = quote.outAmount / quote.inAmount // price of collateral, denominated in debt token, implied by dex quote (D/C) // Compute the absolute value of the difference between the oracle price and dex price let Pcd_dex_oracle_diff: UFix64 = Pcd_dex < Pcd_oracle ? Pcd_oracle - Pcd_dex : Pcd_dex - Pcd_oracle // Compute the percent difference (eg. 0.05 for 5%). Always use the smaller price as the denominator. From 16b6e8def7127fd4cbec961c9d7ce181af17fcb1 Mon Sep 17 00:00:00 2001 From: Jordan Schalm Date: Wed, 7 Jan 2026 10:10:46 -0800 Subject: [PATCH 08/34] rm extraneous comments --- cadence/contracts/FlowCreditMarket.cdc | 3 --- 1 file changed, 3 deletions(-) diff --git a/cadence/contracts/FlowCreditMarket.cdc b/cadence/contracts/FlowCreditMarket.cdc index a58e985..88b7137 100644 --- a/cadence/contracts/FlowCreditMarket.cdc +++ b/cadence/contracts/FlowCreditMarket.cdc @@ -1446,9 +1446,6 @@ access(all) contract FlowCreditMarket { assert(seizeAmount < quote.inAmount, message: "Liquidation offer must be better than that offered by DEX") // Compare the DEX price to the oracle price and revert if they diverge beyond configured threshold. - // Suppose our collateral is X and our debt is Y - // We get a quote for 10X for 100Y -> X=10Y, so Pxy = - // Then the price of collateral denominated in debt is 10$/100FLOW = 0.1$/FLOW = 0.1C/D let Pcd_dex = quote.outAmount / quote.inAmount // price of collateral, denominated in debt token, implied by dex quote (D/C) // Compute the absolute value of the difference between the oracle price and dex price let Pcd_dex_oracle_diff: UFix64 = Pcd_dex < Pcd_oracle ? Pcd_oracle - Pcd_dex : Pcd_dex - Pcd_oracle From 0e0f7838c6b62b9c755eacc435c0afcf0f70768e Mon Sep 17 00:00:00 2001 From: Jordan Schalm Date: Wed, 7 Jan 2026 10:44:00 -0800 Subject: [PATCH 09/34] fix true balance calculation to account for direction --- cadence/contracts/FlowCreditMarket.cdc | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/cadence/contracts/FlowCreditMarket.cdc b/cadence/contracts/FlowCreditMarket.cdc index 88b7137..61477ab 100644 --- a/cadence/contracts/FlowCreditMarket.cdc +++ b/cadence/contracts/FlowCreditMarket.cdc @@ -906,11 +906,15 @@ access(all) contract FlowCreditMarket { access(all) fun trueBalance(ofToken: Type): UFix128 { let balance = self.balances[ofToken]! let tokenSnapshot = self.snapshots[ofToken]! - let trueBalance = FlowCreditMarket.scaledBalanceToTrueBalance( - balance.scaledBalance, - interestIndex: tokenSnapshot.creditIndex - ) - return trueBalance + switch balance.direction { + case BalanceDirection.Debit: + return FlowCreditMarket.scaledBalanceToTrueBalance( + balance.scaledBalance, interestIndex: tokenSnapshot.debitIndex) + case BalanceDirection.Credit: + return FlowCreditMarket.scaledBalanceToTrueBalance( + balance.scaledBalance, interestIndex: tokenSnapshot.creditIndex) + } + panic("unreachable code") } } From 18bfffa29323e3bd0489c90164f38f1d5db1a10e Mon Sep 17 00:00:00 2001 From: Jordan Schalm Date: Wed, 7 Jan 2026 13:14:24 -0800 Subject: [PATCH 10/34] make dex input optional (so contract compiles, for now) --- cadence/contracts/FlowCreditMarket.cdc | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/cadence/contracts/FlowCreditMarket.cdc b/cadence/contracts/FlowCreditMarket.cdc index 61477ab..7dc0fb3 100644 --- a/cadence/contracts/FlowCreditMarket.cdc +++ b/cadence/contracts/FlowCreditMarket.cdc @@ -852,8 +852,8 @@ access(all) contract FlowCreditMarket { liquidationBonus: UFix128 ) { pre { - cf <= 1.0: "collateral factor must be <=1" - bf <= 1.0: "borrow factor must be <=1" + collateralFactor <= 1.0: "collateral factor must be <=1" + borrowFactor <= 1.0: "borrow factor must be <=1" } self.collateralFactor = collateralFactor self.borrowFactor = borrowFactor @@ -1105,7 +1105,7 @@ access(all) contract FlowCreditMarket { /// Allowlist of permitted DeFiActions Swapper types for DEX liquidations // TODO(jord): currently we store an allow-list of swapper types, but access(self) var allowedSwapperTypes: {Type: Bool} - access(self) var dex: {SwapperProvider} + access(self) var dex: {SwapperProvider}? /// Max allowed deviation in basis points between DEX-implied price and oracle price access(self) var dexOracleDeviationBps: UInt16 @@ -1118,7 +1118,7 @@ access(all) contract FlowCreditMarket { // TODO(jord): unused access(self) var dexMaxRouteHops: UInt64 - init(defaultToken: Type, priceOracle: {DeFiActions.PriceOracle}, dex: {SwapperProvider}) { + init(defaultToken: Type, priceOracle: {DeFiActions.PriceOracle}) { pre { priceOracle.unitOfAccount() == defaultToken: "Price oracle must return prices in terms of the default token" @@ -1149,7 +1149,7 @@ access(all) contract FlowCreditMarket { self.lastUnpausedAt = nil self.protocolLiquidationFeeBps = 0 self.allowedSwapperTypes = {} - self.dex = dex + self.dex = nil self.dexOracleDeviationBps = UInt16(300) // 3% default self.dexMaxSlippageBps = 100 self.dexMaxRouteHops = 3 @@ -1444,7 +1444,7 @@ access(all) contract FlowCreditMarket { assert(postHealth <= self.liquidationTargetHF, message: "Liquidation must not exceed target health: \(postHealth)>\(self.liquidationTargetHF)") // Compare the liquidation offer to liquidation via DEX. If the DEX would provide a better price, reject the offer. - let swapper = self.dex.getSwapper(inType: seizeType, outType: debtType)! // TODO: will revert if pair unsupported + let swapper = self.dex!.getSwapper(inType: seizeType, outType: debtType)! // TODO: will revert if pair unsupported // Get a quote: "how much collateral do I need to give you to get `repayAmount` debt tokens" let quote = swapper.quoteIn(forDesired: repayAmount, reverse: false) assert(seizeAmount < quote.inAmount, message: "Liquidation offer must be better than that offered by DEX") From 96a57312b2e19975bc64024fb1880567dfe22c73 Mon Sep 17 00:00:00 2001 From: Jordan Schalm Date: Wed, 7 Jan 2026 13:15:04 -0800 Subject: [PATCH 11/34] update dependencies run flow deps install FlowTransactionScheduler per https://flow-foundation.slack.com/archives/C08QF29F7TK/p1765918094418619?thread_ts=1765904362.928639&cid=C08QF29F7TK to fix imports --- flow.json | 447 +++++++++++++++++++++++++++++------------------------- 1 file changed, 237 insertions(+), 210 deletions(-) diff --git a/flow.json b/flow.json index ce26064..a72b42d 100644 --- a/flow.json +++ b/flow.json @@ -1,155 +1,190 @@ { - "contracts": { - "DeFiActions": { - "source": "./FlowActions/cadence/contracts/interfaces/DeFiActions.cdc", - "aliases": { - "mainnet": "6d888f175c158410", - "testing": "0000000000000006", - "testnet": "0b11b1848a8aa2c0" - } - }, - "DeFiActionsUtils": { - "source": "./FlowActions/cadence/contracts/utils/DeFiActionsUtils.cdc", - "aliases": { - "mainnet": "6d888f175c158410", - "testing": "0000000000000006", - "testnet": "0b11b1848a8aa2c0" - } - }, - "DummyConnectors": { - "source": "./cadence/contracts/mocks/DummyConnectors.cdc", - "aliases": { - "testing": "0000000000000007" - } - }, - "FungibleTokenConnectors": { - "source": "./FlowActions/cadence/contracts/connectors/FungibleTokenConnectors.cdc", - "aliases": { - "testing": "0000000000000006" - } - }, - "MOET": { - "source": "./cadence/contracts/MOET.cdc", - "aliases": { - "testing": "0000000000000007" - } - }, - "MockOracle": { - "source": "./cadence/contracts/mocks/MockOracle.cdc", - "aliases": { - "testing": "0000000000000007" - } - }, - "MockFlowCreditMarketConsumer": { - "source": "./cadence/contracts/mocks/MockFlowCreditMarketConsumer.cdc", - "aliases": { - "testing": "0000000000000008" - } - }, - "MockDexSwapper": { - "source": "./cadence/contracts/mocks/MockDexSwapper.cdc", - "aliases": { - "testing": "0000000000000007" - } - }, - "MockYieldToken": { - "source": "./cadence/contracts/mocks/MockYieldToken.cdc", - "aliases": { - "testing": "0000000000000007" - } - }, - "FlowCreditMarket": { - "source": "./cadence/contracts/FlowCreditMarket.cdc", - "aliases": { - "testing": "0000000000000007" - } - }, - "FlowCreditMarketMath": { - "source": "./cadence/lib/FlowCreditMarketMath.cdc", - "aliases": { - "testing": "0000000000000007" - } - } - }, - "dependencies": { - "Burner": { - "source": "mainnet://f233dcee88fe0abe.Burner", - "hash": "71af18e227984cd434a3ad00bb2f3618b76482842bae920ee55662c37c8bf331", - "aliases": { - "emulator": "f8d6e0586b0a20c7", - "mainnet": "f233dcee88fe0abe", - "testnet": "9a0766d93b6608b7" - } - }, - "FlowToken": { - "source": "mainnet://1654653399040a61.FlowToken", - "hash": "cefb25fd19d9fc80ce02896267eb6157a6b0df7b1935caa8641421fe34c0e67a", - "aliases": { - "emulator": "0ae53cb6e3f42a79", - "mainnet": "1654653399040a61", - "testnet": "7e60df042a9c0868" - } - }, - "FungibleToken": { - "source": "mainnet://f233dcee88fe0abe.FungibleToken", - "hash": "23c1159cf99b2b039b6b868d782d57ae39b8d784045d81597f100a4782f0285b", - "aliases": { - "emulator": "ee82856bf20e2aa6", - "mainnet": "f233dcee88fe0abe", - "testnet": "9a0766d93b6608b7" - } - }, - "FungibleTokenMetadataViews": { - "source": "mainnet://f233dcee88fe0abe.FungibleTokenMetadataViews", - "hash": "dff704a6e3da83997ed48bcd244aaa3eac0733156759a37c76a58ab08863016a", - "aliases": { - "emulator": "ee82856bf20e2aa6", - "mainnet": "f233dcee88fe0abe", - "testnet": "9a0766d93b6608b7" - } - }, - "MetadataViews": { - "source": "mainnet://1d7e57aa55817448.MetadataViews", - "hash": "9032f46909e729d26722cbfcee87265e4f81cd2912e936669c0e6b510d007e81", - "aliases": { - "emulator": "f8d6e0586b0a20c7", - "mainnet": "1d7e57aa55817448", - "testnet": "631e88ae7f1d7c20" - } - }, - "NonFungibleToken": { - "source": "mainnet://1d7e57aa55817448.NonFungibleToken", - "hash": "b63f10e00d1a814492822652dac7c0574428a200e4c26cb3c832c4829e2778f0", - "aliases": { - "emulator": "f8d6e0586b0a20c7", - "mainnet": "1d7e57aa55817448", - "testnet": "631e88ae7f1d7c20" - } - }, - "ViewResolver": { - "source": "mainnet://1d7e57aa55817448.ViewResolver", - "hash": "374a1994046bac9f6228b4843cb32393ef40554df9bd9907a702d098a2987bde", - "aliases": { - "emulator": "f8d6e0586b0a20c7", - "mainnet": "1d7e57aa55817448", - "testnet": "631e88ae7f1d7c20" - } - } - }, - "networks": { - "emulator": "127.0.0.1:3569", - "mainnet": "access.mainnet.nodes.onflow.org:9000", - "testing": "127.0.0.1:3569", - "testnet": "access.devnet.nodes.onflow.org:9000" - }, - "accounts": { - "emulator-account": { - "address": "f8d6e0586b0a20c7", - "key": { - "type": "file", - "location": "emulator-account.pkey" - } - }, + "contracts": { + "DeFiActions": { + "source": "./FlowActions/cadence/contracts/interfaces/DeFiActions.cdc", + "aliases": { + "mainnet": "6d888f175c158410", + "testing": "0000000000000006", + "testnet": "0b11b1848a8aa2c0" + } + }, + "DeFiActionsUtils": { + "source": "./FlowActions/cadence/contracts/utils/DeFiActionsUtils.cdc", + "aliases": { + "mainnet": "6d888f175c158410", + "testing": "0000000000000006", + "testnet": "0b11b1848a8aa2c0" + } + }, + "DummyConnectors": { + "source": "./cadence/contracts/mocks/DummyConnectors.cdc", + "aliases": { + "testing": "0000000000000007" + } + }, + "FlowCreditMarket": { + "source": "./cadence/contracts/FlowCreditMarket.cdc", + "aliases": { + "testing": "0000000000000007" + } + }, + "FlowCreditMarketMath": { + "source": "./cadence/lib/FlowCreditMarketMath.cdc", + "aliases": { + "testing": "0000000000000007" + } + }, + "FungibleTokenConnectors": { + "source": "./FlowActions/cadence/contracts/connectors/FungibleTokenConnectors.cdc", + "aliases": { + "testing": "0000000000000006" + } + }, + "MOET": { + "source": "./cadence/contracts/MOET.cdc", + "aliases": { + "testing": "0000000000000007" + } + }, + "MockDexSwapper": { + "source": "./cadence/contracts/mocks/MockDexSwapper.cdc", + "aliases": { + "testing": "0000000000000007" + } + }, + "MockFlowCreditMarketConsumer": { + "source": "./cadence/contracts/mocks/MockFlowCreditMarketConsumer.cdc", + "aliases": { + "testing": "0000000000000008" + } + }, + "MockOracle": { + "source": "./cadence/contracts/mocks/MockOracle.cdc", + "aliases": { + "testing": "0000000000000007" + } + }, + "MockYieldToken": { + "source": "./cadence/contracts/mocks/MockYieldToken.cdc", + "aliases": { + "testing": "0000000000000007" + } + } + }, + "dependencies": { + "Burner": { + "source": "mainnet://f233dcee88fe0abe.Burner", + "hash": "71af18e227984cd434a3ad00bb2f3618b76482842bae920ee55662c37c8bf331", + "aliases": { + "emulator": "f8d6e0586b0a20c7", + "mainnet": "f233dcee88fe0abe", + "testnet": "9a0766d93b6608b7" + } + }, + "FlowFees": { + "source": "mainnet://f919ee77447b7497.FlowFees", + "hash": "341cc0f3cc847d6b787c390133f6a5e6c867c111784f09c5c0083c47f2f1df64", + "aliases": { + "emulator": "e5a8b7f23e8b548f", + "mainnet": "f919ee77447b7497", + "testnet": "912d5440f7e3769e" + } + }, + "FlowStorageFees": { + "source": "mainnet://e467b9dd11fa00df.FlowStorageFees", + "hash": "a92c26fb2ea59725441fa703aa4cd811e0fc56ac73d649a8e12c1e72b67a8473", + "aliases": { + "emulator": "f8d6e0586b0a20c7", + "mainnet": "e467b9dd11fa00df", + "testnet": "8c5303eaa26202d6" + } + }, + "FlowToken": { + "source": "mainnet://1654653399040a61.FlowToken", + "hash": "f82389e2412624ffa439836b00b42e6605b0c00802a4e485bc95b8930a7eac38", + "aliases": { + "emulator": "0ae53cb6e3f42a79", + "mainnet": "1654653399040a61", + "testnet": "7e60df042a9c0868" + } + }, + "FlowTransactionScheduler": { + "source": "mainnet://e467b9dd11fa00df.FlowTransactionScheduler", + "hash": "c701f26f6a8e993b2573ec8700142f61c9ca936b199af8cc75dee7d9b19c9e95", + "aliases": { + "emulator": "f8d6e0586b0a20c7", + "mainnet": "e467b9dd11fa00df", + "testnet": "8c5303eaa26202d6" + } + }, + "FungibleToken": { + "source": "mainnet://f233dcee88fe0abe.FungibleToken", + "hash": "4b74edfe7d7ddfa70b703c14aa731a0b2e7ce016ce54d998bfd861ada4d240f6", + "aliases": { + "emulator": "ee82856bf20e2aa6", + "mainnet": "f233dcee88fe0abe", + "testnet": "9a0766d93b6608b7" + } + }, + "FungibleTokenMetadataViews": { + "source": "mainnet://f233dcee88fe0abe.FungibleTokenMetadataViews", + "hash": "70477f80fd7678466c224507e9689f68f72a9e697128d5ea54d19961ec856b3c", + "aliases": { + "emulator": "ee82856bf20e2aa6", + "mainnet": "f233dcee88fe0abe", + "testnet": "9a0766d93b6608b7" + } + }, + "MetadataViews": { + "source": "mainnet://1d7e57aa55817448.MetadataViews", + "hash": "b290b7906d901882b4b62e596225fb2f10defb5eaaab4a09368f3aee0e9c18b1", + "aliases": { + "emulator": "f8d6e0586b0a20c7", + "mainnet": "1d7e57aa55817448", + "testnet": "631e88ae7f1d7c20" + } + }, + "NonFungibleToken": { + "source": "mainnet://1d7e57aa55817448.NonFungibleToken", + "hash": "a258de1abddcdb50afc929e74aca87161d0083588f6abf2b369672e64cf4a403", + "aliases": { + "emulator": "f8d6e0586b0a20c7", + "mainnet": "1d7e57aa55817448", + "testnet": "631e88ae7f1d7c20" + } + }, + "ViewResolver": { + "source": "mainnet://1d7e57aa55817448.ViewResolver", + "hash": "374a1994046bac9f6228b4843cb32393ef40554df9bd9907a702d098a2987bde", + "aliases": { + "emulator": "f8d6e0586b0a20c7", + "mainnet": "1d7e57aa55817448", + "testnet": "631e88ae7f1d7c20" + } + } + }, + "networks": { + "emulator": "127.0.0.1:3569", + "mainnet": "access.mainnet.nodes.onflow.org:9000", + "testing": "127.0.0.1:3569", + "testnet": "access.devnet.nodes.onflow.org:9000" + }, + "accounts": { + "emulator-account": { + "address": "f8d6e0586b0a20c7", + "key": { + "type": "file", + "location": "emulator-account.pkey" + } + }, + "mainnet-deployer": { + "address": "6b00ff876c299c61", + "key": { + "type": "google-kms", + "hashAlgorithm": "SHA2_256", + "resourceID": "projects/dl-flow-devex-production/locations/us-central1/keyRings/tidal-keyring/cryptoKeys/tidal_admin_pk/cryptoKeyVersions/1" + } + }, "testnet-deployer": { "address": "426f0458ced60037", "key": { @@ -157,63 +192,55 @@ "hashAlgorithm": "SHA2_256", "resourceID": "projects/dl-flow-devex-staging/locations/us-central1/keyRings/tidal-keyring/cryptoKeys/tidal_admin_pk/cryptoKeyVersions/1" } + } + }, + "deployments": { + "emulator": { + "emulator-account": [ + "DeFiActionsUtils", + "DeFiActions", + "FlowCreditMarketMath", + { + "name": "MOET", + "args": [ + { + "value": "1000000.00000000", + "type": "UFix64" + } + ] + }, + "FlowCreditMarket" + ] }, - "mainnet-deployer": { - "address": "6b00ff876c299c61", - "key": { - "type": "google-kms", - "hashAlgorithm": "SHA2_256", - "resourceID": "projects/dl-flow-devex-production/locations/us-central1/keyRings/tidal-keyring/cryptoKeys/tidal_admin_pk/cryptoKeyVersions/1" - } - } - }, - "deployments": { - "emulator": { - "emulator-account": [ - "DeFiActionsUtils", - "DeFiActions", - "FlowCreditMarketMath", - { - "name": "MOET", - "args": [ - { - "value": "1000000.00000000", - "type": "UFix64" - } - ] - }, - "FlowCreditMarket" - ] - }, - "testnet": { - "testnet-deployer": [ - "FlowCreditMarketMath", - { - "name": "MOET", - "args": [ - { - "value": "0.0", - "type": "UFix64" - } - ] - }, - "FlowCreditMarket" - ] - }, - "mainnet": { - "mainnet-deployer": [ - "FlowCreditMarketMath", - { - "name": "MOET", - "args": [ - { - "value": "0.0", - "type": "UFix64" - } - ] - }, - "FlowCreditMarket" - ] - } - } -} + "mainnet": { + "mainnet-deployer": [ + "FlowCreditMarketMath", + { + "name": "MOET", + "args": [ + { + "value": "0.00000000", + "type": "UFix64" + } + ] + }, + "FlowCreditMarket" + ] + }, + "testnet": { + "testnet-deployer": [ + "FlowCreditMarketMath", + { + "name": "MOET", + "args": [ + { + "value": "0.00000000", + "type": "UFix64" + } + ] + }, + "FlowCreditMarket" + ] + } + } +} \ No newline at end of file From 4dab0e709af477bd8bdc3cfe5df36a0954a89f9a Mon Sep 17 00:00:00 2001 From: Jordan Schalm Date: Wed, 7 Jan 2026 13:52:13 -0800 Subject: [PATCH 12/34] remove LiquidationResult --- cadence/contracts/FlowCreditMarket.cdc | 96 ++++++++------------------ 1 file changed, 29 insertions(+), 67 deletions(-) diff --git a/cadence/contracts/FlowCreditMarket.cdc b/cadence/contracts/FlowCreditMarket.cdc index 7dc0fb3..5809fa6 100644 --- a/cadence/contracts/FlowCreditMarket.cdc +++ b/cadence/contracts/FlowCreditMarket.cdc @@ -1382,28 +1382,30 @@ access(all) contract FlowCreditMarket { /// Any external party can perform a manual liquidation on a position under the following circumstances: /// - the position has health < 1 /// - the liquidation price offered is better than what is available on a DEX - // - the liquidation results in a health <= liquidationTargetHF - // - // Terminology: - // - N means number of some token: Nc means number of collateral tokens, Nd means number of debt tokens - // - P means price of some token: Pc, Pd mean price of collateral, - // - C means collateral: Ce is effective collateral, Ct is true collateral, measured in $ - // - D means debt: De is effective debt, Dt is true debt, measured in $ - // - Fc, Fd are collateral and debt factors - // - // Test cases: - // - proposal brings health above target (not allowed) - // - proposal reduces health (allowed) - // - proposal pays more debt than position has - // - proposal seizes more collateral than position has + /// - the liquidation results in a health <= liquidationTargetHF + /// + /// If a liquidation attempt is successful, the balance of the input `repayment` vault is deposited to the pool + /// and a vault containing a balance of `seizeAmount` collateral tokens are returned to the caller. + /// + /// Terminology: + /// - N means number of some token: Nc means number of collateral tokens, Nd means number of debt tokens + /// - P means price of some token: Pc, Pd mean price of collateral, + /// - C means collateral: Ce is effective collateral, Ct is true collateral, measured in $ + /// - D means debt: De is effective debt, Dt is true debt, measured in $ + /// - Fc, Fd are collateral and debt factors + /// + /// TODO: Test cases: + /// - proposal brings health above target (not allowed) + /// - proposal reduces health (allowed) + /// - proposal pays more debt than position has + /// - proposal seizes more collateral than position has access(all) fun manualLiquidation( pid: UInt64, debtType: Type, - repayAmount: UFix64, // TODO does it need to be ufix64 on public api boundary? seizeType: Type, seizeAmount: UFix64, - repaymentSource: @{FungibleToken.Vault} - ): @FlowCreditMarket.LiquidationResult { + repayment: @{FungibleToken.Vault} + ): @{FungibleToken.Vault} { pre { // debt, collateral are both supported tokens // repaymentSource has sufficient balance @@ -1418,6 +1420,7 @@ access(all) contract FlowCreditMarket { assert(initialHealth >= 1.0, message: "Cannot liquidate healthy position: \(initialHealth)>1") // Ensure liquidation amounts don't exceed position amounts + let repayAmount = repayment.balance let Nc = positionView.trueBalance(ofToken: seizeType) // number of collateral tokens (true balance) let Nd = positionView.trueBalance(ofToken: debtType) // number of debt tokens (true balance) assert(UFix128(seizeAmount) <= Nc, message: "Cannot seize more collateral than is in position: \(Nc)<\(seizeAmount))") @@ -1460,19 +1463,19 @@ access(all) contract FlowCreditMarket { assert(Pcd_dex_oracle_diffBps <= self.dexOracleDeviationBps, message: "Too large difference between dex/oracle prices diff=\(Pcd_dex_oracle_diffBps)bps") // Execute the liquidation - return <- self._doLiquidation(pid: pid, from: <-repaymentSource, debtType: debtType, seizeType: seizeType, seizeAmount: seizeAmount) + return <- self._doLiquidation(pid: pid, repayment: <-repayment, debtType: debtType, seizeType: seizeType, seizeAmount: seizeAmount) } - // Internal liquidation function which performs a liquidation - // Callers are responsible for checking preconditions. - access(self) fun _doLiquidation(pid: UInt64, from: @{FungibleToken.Vault}, debtType: Type, seizeType: Type, seizeAmount: UFix64): @LiquidationResult { + /// Internal liquidation function which performs a liquidation. + /// The balance of `repayment` is deposited to the debt token reserve, and `seizeAmount` units of collateral are returned. + /// Callers are responsible for checking preconditions. + access(self) fun _doLiquidation(pid: UInt64, repayment: @{FungibleToken.Vault}, debtType: Type, seizeType: Type, seizeAmount: UFix64): @{FungibleToken.Vault} { pre { // position must have debt and collateral balance } - let repayAmount = from.balance - let repayment <- from.withdraw(amount: repayAmount) - assert(from.getType() == debtType, message: "Vault type mismatch for repay") + let repayAmount = repayment.balance + assert(repayment.getType() == debtType, message: "Vault type mismatch for repay") let debtReserveRef = (&self.reserves[debtType] as auth(FungibleToken.Withdraw) &{FungibleToken.Vault}?)! debtReserveRef.deposit(from: <-repayment) @@ -1492,14 +1495,14 @@ access(all) contract FlowCreditMarket { } position.balances[seizeType]!.recordWithdrawal(amount: UFix128(seizeAmount), tokenState: seizeState) let seizeReserveRef = (&self.reserves[seizeType] as auth(FungibleToken.Withdraw) &{FungibleToken.Vault}?)! - let payout <- seizeReserveRef.withdraw(amount: seizeAmount) + let seizedCollateral <- seizeReserveRef.withdraw(amount: seizeAmount) let newHealth = self.positionHealth(pid: pid) // TODO: sanity check health here? for auto-liquidating, we may need to perform a bounded search which could result in unbounded error in the final health emit LiquidationExecuted(pid: pid, poolUUID: self.uuid, debtType: debtType.identifier, repayAmount: repayAmount, seizeType: seizeType.identifier, seizeAmount: seizeAmount, newHF: newHealth) - return <- create LiquidationResult(seized: <-payout, remainder: <-from) + return <-seizedCollateral } /// Quote liquidation required repay and seize amounts to bring HF to liquidationTargetHF @@ -4139,45 +4142,4 @@ access(all) contract FlowCreditMarket { ) let factory = self.account.storage.borrow<&PoolFactory>(from: self.PoolFactoryPath)! } - - /// TODO(jord): document or remove + move to where other resources are defined - access(all) resource LiquidationResult: Burner.Burnable { - access(all) var seized: @{FungibleToken.Vault}? - access(all) var remainder: @{FungibleToken.Vault}? - - init( - seized: @{FungibleToken.Vault}, - remainder: @{FungibleToken.Vault} - ) { - self.seized <- seized - self.remainder <- remainder - } - - access(all) fun takeSeized(): @{FungibleToken.Vault} { - let s <- self.seized <- nil - return <- s! - } - - access(all) fun takeRemainder(): @{FungibleToken.Vault} { - let r <- self.remainder <- nil - return <- r! - } - - access(contract) fun burnCallback() { - let s <- self.seized <- nil - let r <- self.remainder <- nil - if s != nil { - Burner.burn(<-s) - } else { - destroy s - } - if r != nil { - Burner.burn(<-r) - } else { - destroy r - } - } - } - - // (contract-level helpers removed; resource-scoped versions live in Pool) } From 35be7effb5af54d128772324152d0e95db7f0862 Mon Sep 17 00:00:00 2001 From: Jordan Schalm Date: Wed, 7 Jan 2026 13:55:09 -0800 Subject: [PATCH 13/34] remove old liquidation logic methods --- cadence/contracts/FlowCreditMarket.cdc | 632 +------------------------ 1 file changed, 1 insertion(+), 631 deletions(-) diff --git a/cadence/contracts/FlowCreditMarket.cdc b/cadence/contracts/FlowCreditMarket.cdc index 5809fa6..3762b44 100644 --- a/cadence/contracts/FlowCreditMarket.cdc +++ b/cadence/contracts/FlowCreditMarket.cdc @@ -1504,334 +1504,7 @@ access(all) contract FlowCreditMarket { return <-seizedCollateral } - - /// Quote liquidation required repay and seize amounts to bring HF to liquidationTargetHF - /// using a single seizeType - access(all) fun quoteLiquidation(pid: UInt64, debtType: Type, seizeType: Type): FlowCreditMarket.LiquidationQuote { - pre { - self.globalLedger[debtType] != nil: - "Invalid debt type \(debtType.identifier)" - self.globalLedger[seizeType] != nil: - "Invalid seize type \(seizeType.identifier)" - } - let view = self.buildPositionView(pid: pid) - let health = FlowCreditMarket.healthFactor(view: view) - if health >= 1.0 { - return FlowCreditMarket.LiquidationQuote( - requiredRepay: 0.0, - seizeType: seizeType, - seizeAmount: 0.0, - newHF: health - ) - } - - // Build snapshots - let debtState = self._borrowUpdatedTokenState(type: debtType) - let seizeState = self._borrowUpdatedTokenState(type: seizeType) - - // Resolve per-token liquidation bonus (default 5%) for debtType - let lbDebtUFix = self.liquidationBonus[debtType] ?? 0.05 - let debtSnap = FlowCreditMarket.TokenSnapshot( - price: UFix128(self.priceOracle.price(ofToken: debtType)!), - credit: debtState.creditInterestIndex, - debit: debtState.debitInterestIndex, - risk: FlowCreditMarket.RiskParams( - collateralFactor: UFix128(self.collateralFactor[debtType]!), - borrowFactor: UFix128(self.borrowFactor[debtType]!), - liquidationBonus: UFix128(lbDebtUFix) - ) - ) - // Resolve per-token liquidation bonus (default 5%) for seizeType - let lbSeizeUFix = self.liquidationBonus[seizeType] ?? 0.05 - let seizeSnap = FlowCreditMarket.TokenSnapshot( - price: UFix128(self.priceOracle.price(ofToken: seizeType)!), - credit: seizeState.creditInterestIndex, - debit: seizeState.debitInterestIndex, - risk: FlowCreditMarket.RiskParams( - collateralFactor: UFix128(self.collateralFactor[seizeType]!), - borrowFactor: UFix128(self.borrowFactor[seizeType]!), - liquidationBonus: UFix128(lbSeizeUFix) - ) - ) - - // Recompute effective totals and capture available true collateral for seizeType - var effColl: UFix128 = 0.0 - var effDebt: UFix128 = 0.0 - var trueCollateralSeize: UFix128 = 0.0 // includes accrued interest - var trueDebt: UFix128 = 0.0 - for t in view.balances.keys { - let b = view.balances[t]! - let st = self._borrowUpdatedTokenState(type: t) - // Resolve per-token liquidation bonus (default 5%) for token t - let lbTUFix = self.liquidationBonus[t] ?? 0.05 - let snap = FlowCreditMarket.TokenSnapshot( - price: UFix128(self.priceOracle.price(ofToken: t)!), - credit: st.creditInterestIndex, - debit: st.debitInterestIndex, - risk: FlowCreditMarket.RiskParams( - collateralFactor: UFix128(self.collateralFactor[t]!), - borrowFactor: UFix128(self.borrowFactor[t]!), - liquidationBonus: UFix128(lbTUFix) - ) - ) - switch b.direction { - case BalanceDirection.Credit: - let trueBal = FlowCreditMarket.scaledBalanceToTrueBalance( - b.scaledBalance, - interestIndex: snap.creditIndex - ) - if t == seizeType { - trueCollateralSeize = trueBal - } - effColl = effColl + FlowCreditMarket.effectiveCollateral(credit: trueBal, snap: snap) - - case BalanceDirection.Debit: - let trueBal = FlowCreditMarket.scaledBalanceToTrueBalance( - b.scaledBalance, - interestIndex: snap.debitIndex - ) - if t == debtType { - trueDebt = trueBal - } - effDebt = effDebt + FlowCreditMarket.effectiveDebt(debit: trueBal, snap: snap) - } - } - - // Compute required effective collateral increase to reach targetHF - let target = self.liquidationTargetHF - if effDebt == 0.0 { // no debt - return FlowCreditMarket.LiquidationQuote( - requiredRepay: 0.0, - seizeType: seizeType, - seizeAmount: 0.0, - newHF: UFix128.max - ) - } - - let requiredEffColl = effDebt * target - if effColl >= requiredEffColl { - // TODO(jord): I think this is an unexpected path? - // - liquidationTargetHF must be >1, otherwise we would liquidate positions into still-unhealthy states - // - we check that health<1 at the top of this function - // - effColl >= requiredEffColl iff heath>=1 - // - therefore this branch should never execute - return FlowCreditMarket.LiquidationQuote( - requiredRepay: 0.0, - seizeType: seizeType, - seizeAmount: 0.0, - newHF: health - ) - } - - let deltaEffColl = requiredEffColl - effColl - - // Paying debt reduces effectiveDebt instead of increasing collateral. Solve for repay needed in debt token terms: - // effDebtNew = effDebt - (repayTrue * debtSnap.price / debtSnap.risk.borrowFactor) - // target = effColl / effDebtNew => effDebtNew = effColl / target - // So reductionNeeded = effDebt - effColl/target - // - // TODO(jord): - // This equation doesn't make sense to me: - // target = effColl / effDebtNew - // - // - we are trying to determine what final effective debt amount is necessary to bring health factor to target - // - the equation is using effColl, which is the effective collateral BEFORE the liquidation occurs - // alongside effDebtNew, which is the effective debt AFTER the liquidation occurs - // - however, the liquidation involves two things: - // - liquidator provides MOET - // - liquidator seizes collateral - // - this implies that effColl will decrease during the liquidation - // - therefore the equation does not represent a health factor at a specific point in time? - let effDebtNew = effColl / target - if effDebt <= effDebtNew { - // TODO(jord): I think passing in target as newHF here is wrong (although probably not dangerous) - return FlowCreditMarket.LiquidationQuote( - requiredRepay: 0.0, - seizeType: seizeType, - seizeAmount: 0.0, - newHF: target - ) - } - - // Use simultaneous solve below; the approximate path is omitted - - // New simultaneous solve for repayTrue (let R = repayTrue, S = seizeTrue): - // Target HF = (effColl - S * Pc * CF) / (effDebt - R * Pd / BF) - // S = (R * Pd / BF) * (1 + LB) / (Pc * CF) - // Solve for R such that HF = target - let Pd = debtSnap.price - let Pc = seizeSnap.price - let BF = debtSnap.risk.borrowFactor - let CF = seizeSnap.risk.collateralFactor - let LB = seizeSnap.risk.liquidationBonus - - // Reuse previously computed effective collateral and debt - - if effDebt == 0.0 || effColl / effDebt >= target { - return FlowCreditMarket.LiquidationQuote( - requiredRepay: 0.0, - seizeType: seizeType, - seizeAmount: 0.0, - newHF: effColl / effDebt - ) - } - - // Derived formula with positive denominator: u = (t * effDebt - effColl) / (t - (1 + LB) * CF) - // TODO: What is u? - // num = (t * effDebt - effColl) - let num = effDebt * target - effColl - // denomFactor = (t - (1 + LB) * CF) - let denomFactor = target - ((1.0 + LB) * CF) - if denomFactor <= 0.0 { - // Impossible target, return 0 - return FlowCreditMarket.LiquidationQuote( - requiredRepay: 0.0, - seizeType: seizeType, - seizeAmount: 0.0, - newHF: health - ) - } - // repayTrueU128 = (num * BF) / (Pd * denomFactor) - // repayTrueU128 = [(t * effDebt - effColl)(BF)] / [(Pd)(t - (1+LB)*CF))] - var repayTrueU128 = (num * BF) / (Pd * denomFactor) - if repayTrueU128 > trueDebt { - repayTrueU128 = trueDebt - } - let u = (repayTrueU128 * Pd) / BF - var seizeTrueU128 = (u * (1.0 + LB)) / Pc - if seizeTrueU128 > trueCollateralSeize { - seizeTrueU128 = trueCollateralSeize - let uAllowed = (seizeTrueU128 * Pc) / (1.0 + LB) - repayTrueU128 = (uAllowed * BF) / Pd - if repayTrueU128 > trueDebt { - repayTrueU128 = trueDebt - } - } - let repayExact = FlowCreditMarketMath.toUFix64RoundUp(repayTrueU128) - let seizeExact = FlowCreditMarketMath.toUFix64RoundUp(seizeTrueU128) - let repayEff = (repayTrueU128 * Pd) / BF - let seizeEff = seizeTrueU128 * (Pc * CF) - let newEffColl = effColl > seizeEff ? effColl - seizeEff : 0.0 as UFix128 - let newEffDebt = effDebt > repayEff ? effDebt - repayEff : 0.0 as UFix128 - let newHF = newEffDebt == 0.0 ? UFix128.max : (newEffColl * 1.0) / newEffDebt - - // Prevent liquidation if it would worsen HF (deep insolvency case). - // Enhanced fallback: search for the repay/seize pair (under protocol pricing relation - // and available-collateral/debt caps) that maximizes HF. We discretize the search to keep costs bounded. - if newHF < health { - // Compute the maximum repay allowed by available seize collateral (Rcap), preserving R<->S pricing relation. - // uAllowed = seizeTrue * Pc / (1 + LB) - let uAllowedMax = (trueCollateralSeize * Pc) / (1.0 + LB) - var repayCapBySeize = (uAllowedMax * BF) / Pd - if repayCapBySeize > trueDebt { - repayCapBySeize = trueDebt - } - - var bestHF = health - var bestRepayTrue: UFix128 = 0.0 - var bestSeizeTrue: UFix128 = 0.0 - - // If nothing can be repaid or seized, abort with no quote - if repayCapBySeize == 0.0 || trueCollateralSeize == 0.0 { - return FlowCreditMarket.LiquidationQuote( - requiredRepay: 0.0, - seizeType: seizeType, - seizeAmount: 0.0, - newHF: health - ) - } - - // Discrete bounded search over repay in [1..repayCapBySeize] - // Use up to 16 steps to balance precision and cost - let stepsU: UFix128 = 16.0 - var step = repayCapBySeize / stepsU - if step == 0.0 { - step = 1.0 - } - - var r = step - while r <= repayCapBySeize { - // Compute S for this R under pricing relation, capped by available collateral - let uForR = (r * Pd) / BF - var sForR = (uForR * (1.0 + LB)) / Pc - if sForR > trueCollateralSeize { - sForR = trueCollateralSeize - } - - // Compute resulting HF - let repayEffC = (r * Pd) / BF - let seizeEffC = sForR * (Pc * CF) - let newEffCollC = effColl > seizeEffC ? effColl - seizeEffC : 0.0 as UFix128 - let newEffDebtC = effDebt > repayEffC ? effDebt - repayEffC : 0.0 as UFix128 - let newHFC = newEffDebtC == 0.0 ? UFix128.max : (newEffCollC * 1.0) / newEffDebtC - - if newHFC > bestHF { - bestHF = newHFC - bestRepayTrue = r - bestSeizeTrue = sForR - } - - // Advance; ensure we always reach the cap - let next = r + step - if next > repayCapBySeize { - break - } - r = next - } - - // Also evaluate at the cap explicitly (in case step didn't land exactly) - let rCap = repayCapBySeize - let uForR2 = (rCap * Pd) / BF - var sForR2 = (uForR2 * (1.0 + LB)) / Pc - if sForR2 > trueCollateralSeize { - sForR2 = trueCollateralSeize - } - let repayEffC2 = (rCap * Pd) / BF - let seizeEffC2 = sForR2 * (Pc * CF) - let newEffCollC2 = effColl > seizeEffC2 ? effColl - seizeEffC2 : 0.0 as UFix128 - let newEffDebtC2 = effDebt > repayEffC2 ? effDebt - repayEffC2 : 0.0 as UFix128 - let newHFC2 = newEffDebtC2 == 0.0 ? UFix128.max : (newEffCollC2 * 1.0) / newEffDebtC2 - if newHFC2 > bestHF { - bestHF = newHFC2 - bestRepayTrue = rCap - bestSeizeTrue = sForR2 - } - - if bestHF > health && bestRepayTrue > 0.0 && bestSeizeTrue > 0.0 { - let repayExactBest = FlowCreditMarketMath.toUFix64RoundUp(bestRepayTrue) - let seizeExactBest = FlowCreditMarketMath.toUFix64RoundUp(bestSeizeTrue) - if self.debugLogging { - log("[LIQ][QUOTE][FALLBACK][SEARCH] repayExact=\(repayExactBest) seizeExact=\(seizeExactBest)") - } - return FlowCreditMarket.LiquidationQuote( - requiredRepay: repayExactBest, - seizeType: seizeType, - seizeAmount: seizeExactBest, - newHF: bestHF - ) - } - - // TODO(jord): This is where we may disallow liquidation if it does not improve health factor - // No improving pair found - return FlowCreditMarket.LiquidationQuote( - requiredRepay: 0.0, - seizeType: seizeType, - seizeAmount: 0.0, - newHF: health - ) - } - - if self.debugLogging { - log("[LIQ][QUOTE] repayExact=\(repayExact) seizeExact=\(seizeExact) trueCollateralSeize=\(FlowCreditMarketMath.toUFix64Round(trueCollateralSeize))") - } - return FlowCreditMarket.LiquidationQuote( - requiredRepay: repayExact, - seizeType: seizeType, - seizeAmount: seizeExact, - newHF: newHF - ) - } - + /// Returns the quantity of funds of a specified token which would need to be deposited /// in order to bring the position to the target health /// assuming we also withdraw a specified amount of another token. @@ -1869,309 +1542,6 @@ access(all) contract FlowCreditMarket { ) } - /// Permissionless liquidation: keeper repays exactly the required amount to reach target HF - /// and receives seized collateral - access(all) fun liquidateRepayForSeize( - pid: UInt64, - debtType: Type, - maxRepayAmount: UFix64, - seizeType: Type, - minSeizeAmount: UFix64, - from: @{FungibleToken.Vault} - ): @LiquidationResult { - pre { - self.globalLedger[debtType] != nil: - "Invalid debt type \(debtType.identifier)" - self.globalLedger[seizeType] != nil: - "Invalid seize type \(seizeType.identifier)" - } - // Pause/warm-up checks - self._assertLiquidationsActive() - - // Quote required repay and seize - let quote = self.quoteLiquidation( - pid: pid, - debtType: debtType, - seizeType: seizeType - ) - assert( - quote.requiredRepay > 0.0, - message: "Position not liquidatable or already healthy" - ) - assert( - maxRepayAmount >= quote.requiredRepay, - message: "Insufficient max repay" - ) - assert( - quote.seizeAmount >= minSeizeAmount, - message: "Seize amount below minimum" - ) - - // Ensure internal reserves exist for seizeType and debtType - if self.reserves[seizeType] == nil { - self.reserves[seizeType] <-! DeFiActionsUtils.getEmptyVault(seizeType) - } - if self.reserves[debtType] == nil { - self.reserves[debtType] <-! DeFiActionsUtils.getEmptyVault(debtType) - } - - // Move repay tokens into reserves (repay vault must exactly match requiredRepay) - assert( - from.getType() == debtType, - message: "Vault type mismatch for repay" - ) - assert( - from.balance >= quote.requiredRepay, - message: "Repay vault balance must be at least requiredRepay" - ) - let toUse <- from.withdraw(amount: quote.requiredRepay) - let debtReserveRef = (&self.reserves[debtType] as auth(FungibleToken.Withdraw) &{FungibleToken.Vault}?)! - debtReserveRef.deposit(from: <-toUse) - - // Reduce borrower's debt position by repayAmount - let position = self._borrowPosition(pid: pid) - let debtState = self._borrowUpdatedTokenState(type: debtType) - let repayUint = UFix128(quote.requiredRepay) - if position.balances[debtType] == nil { - position.balances[debtType] = InternalBalance( - direction: BalanceDirection.Debit, - scaledBalance: 0.0 - ) - } - position.balances[debtType]!.recordDeposit( - amount: repayUint, - tokenState: debtState - ) - - // Withdraw seized collateral from position and send to liquidator - let seizeState = self._borrowUpdatedTokenState(type: seizeType) - let seizeUint = UFix128(quote.seizeAmount) - if position.balances[seizeType] == nil { - position.balances[seizeType] = InternalBalance( - direction: BalanceDirection.Credit, - scaledBalance: 0.0 - ) - } - position.balances[seizeType]!.recordWithdrawal( - amount: seizeUint, - tokenState: seizeState - ) - let seizeReserveRef = (&self.reserves[seizeType] as auth(FungibleToken.Withdraw) &{FungibleToken.Vault}?)! - let payout <- seizeReserveRef.withdraw(amount: quote.seizeAmount) - - let actualNewHF = self.positionHealth(pid: pid) - // Ensure realized HF is not materially below quoted HF (allow tiny rounding tolerance) - let expectedHF = quote.newHF - let hfTolerance: UFix128 = 0.00001 - assert( - actualNewHF + hfTolerance >= expectedHF, - message: "Post-liquidation HF below expected" - ) - - emit LiquidationExecuted( - pid: pid, - poolUUID: self.uuid, - debtType: debtType.identifier, - repayAmount: quote.requiredRepay, - seizeType: seizeType.identifier, - seizeAmount: quote.seizeAmount, - newHF: actualNewHF - ) - - return <- create LiquidationResult(seized: <-payout, remainder: <-from) - } - - /// Liquidation via DEX: seize collateral, swap via allowlisted Swapper to debt token, repay debt - access(all) fun liquidateViaDex( - pid: UInt64, - debtType: Type, - seizeType: Type, - maxSeizeAmount: UFix64, - minRepayAmount: UFix64, - swapper: {DeFiActions.Swapper}, - quote: {DeFiActions.Quote}? - ) { - pre { - self.globalLedger[debtType] != nil: - "Invalid debt type \(debtType.identifier)" - self.globalLedger[seizeType] != nil: - "Invalid seize type \(seizeType.identifier)" - !self.liquidationsPaused: - "Liquidations paused" - } - self._assertLiquidationsActive() - - // Ensure reserve vaults exist for both tokens - if self.reserves[seizeType] == nil { - self.reserves[seizeType] <-! DeFiActionsUtils.getEmptyVault(seizeType) - } - if self.reserves[debtType] == nil { - self.reserves[debtType] <-! DeFiActionsUtils.getEmptyVault(debtType) - } - - // Validate position is liquidatable - let health = self.positionHealth(pid: pid) - assert( - health < 1.0, - message: "Position not liquidatable" - ) - assert( - self.isLiquidatable(pid: pid), - message: "Position \(pid) is not liquidatable" - ) - - // Internal quote to determine required seize (capped by max) - let internalQuote = self.quoteLiquidation( - pid: pid, - debtType: debtType, - seizeType: seizeType - ) - var requiredSeize = internalQuote.seizeAmount - if requiredSeize > maxSeizeAmount { - requiredSeize = maxSeizeAmount - } - assert( - requiredSeize > 0.0, - message: "Nothing to seize" - ) - - // Allowlist/type checks - assert( - self.allowedSwapperTypes[swapper.getType()] == true, - message: "Swapper not allowlisted" - ) - assert( - swapper.inType() == seizeType, - message: "Swapper must accept seizeType \(seizeType.identifier)" - ) - assert( - swapper.outType() == debtType, - message: "Swapper must output debtType \(debtType.identifier)" - ) - - // Oracle vs DEX price deviation guard - let Pc = self.priceOracle.price(ofToken: seizeType)! - let Pd = self.priceOracle.price(ofToken: debtType)! - let dexQuote = quote - ?? swapper.quoteOut( - forProvided: requiredSeize, - reverse: false - ) - let dexOut = dexQuote.outAmount - let impliedPrice = dexOut / requiredSeize - let oraclePrice = Pd / Pc - let deviation = impliedPrice > oraclePrice - ? impliedPrice - oraclePrice - : oraclePrice - impliedPrice - let deviationBps = UInt16((deviation / oraclePrice) * 10000.0) - assert( - deviationBps <= self.dexOracleDeviationBps, - message: "DEX price deviates too high" - ) - - // Seize collateral and swap - let seized <- self.internalSeize( - pid: pid, - tokenType: seizeType, - amount: requiredSeize - ) - let outDebt <- swapper.swap(quote: dexQuote, inVault: <-seized) - assert( - outDebt.getType() == debtType, - message: "Swapper returned wrong out type" - ) - - // Slippage guard if quote provided - var slipBps: UInt16 = 0 - // Slippage vs expected from oracle prices - let expectedOutFromOracle = requiredSeize * (Pd / Pc) - if expectedOutFromOracle > 0.0 { - let diff = outDebt.balance > expectedOutFromOracle - ? outDebt.balance - expectedOutFromOracle - : expectedOutFromOracle - outDebt.balance - let frac = diff / expectedOutFromOracle - let bpsU = frac * 10000.0 - slipBps = UInt16(bpsU) - assert( - UInt64(slipBps) <= self.dexMaxSlippageBps, - message: "Swap slippage too high" - ) - } - - // Repay debt using swap output - let repaid = self.internalRepay(pid: pid, from: <-outDebt) - assert( - repaid >= minRepayAmount, - message: "Insufficient repay after swap - required \(minRepayAmount) but repaid \(repaid)" - ) - - // Optional safety: ensure improved health meets target - let postHF = self.positionHealth(pid: pid) - assert( - postHF >= self.liquidationTargetHF, - message: "Post-liquidation HF below target" - ) - - emit LiquidationExecutedViaDex( - pid: pid, - poolUUID: self.uuid, - seizeType: seizeType.identifier, - seized: requiredSeize, - debtType: debtType.identifier, - repaid: repaid, - slippageBps: slipBps, - newHF: self.positionHealth(pid: pid) - ) - } - - // Internal helpers for DEX liquidation path (resource-scoped) - - access(self) fun internalSeize(pid: UInt64, tokenType: Type, amount: UFix64): @{FungibleToken.Vault} { - let position = self._borrowPosition(pid: pid) - let tokenState = self._borrowUpdatedTokenState(type: tokenType) - let seizeUint = UFix128(amount) - if position.balances[tokenType] == nil { - position.balances[tokenType] = InternalBalance( - direction: BalanceDirection.Credit, - scaledBalance: 0.0 - ) - } - position.balances[tokenType]!.recordWithdrawal( - amount: seizeUint, - tokenState: tokenState - ) - if self.reserves[tokenType] == nil { - self.reserves[tokenType] <-! DeFiActionsUtils.getEmptyVault(tokenType) - } - let reserveRef = (&self.reserves[tokenType] as auth(FungibleToken.Withdraw) &{FungibleToken.Vault}?)! - return <- reserveRef.withdraw(amount: amount) - } - - access(self) fun internalRepay(pid: UInt64, from: @{FungibleToken.Vault}): UFix64 { - let debtType = from.getType() - if self.reserves[debtType] == nil { - self.reserves[debtType] <-! DeFiActionsUtils.getEmptyVault(debtType) - } - let toDeposit <- from - let amount = toDeposit.balance - let reserveRef = (&self.reserves[debtType] as &{FungibleToken.Vault}?)! - reserveRef.deposit(from: <-toDeposit) - let position = self._borrowPosition(pid: pid) - let debtState = self._borrowUpdatedTokenState(type: debtType) - let repayUint = UFix128(amount) - if position.balances[debtType] == nil { - position.balances[debtType] = InternalBalance( - direction: BalanceDirection.Debit, - scaledBalance: 0.0 - ) - } - position.balances[debtType]!.recordDeposit( - amount: repayUint, - tokenState: debtState - ) - return amount - } - access(self) fun computeAdjustedBalancesAfterWithdrawal( balanceSheet: BalanceSheet, position: &InternalPosition, From 9f8d070059fa41f3bf5fc97fd3a94b04ecffce99 Mon Sep 17 00:00:00 2001 From: Jordan Schalm Date: Wed, 7 Jan 2026 14:09:02 -0800 Subject: [PATCH 14/34] remove protocol fee --- cadence/contracts/FlowCreditMarket.cdc | 28 +++++++++----------------- 1 file changed, 10 insertions(+), 18 deletions(-) diff --git a/cadence/contracts/FlowCreditMarket.cdc b/cadence/contracts/FlowCreditMarket.cdc index 3762b44..7131079 100644 --- a/cadence/contracts/FlowCreditMarket.cdc +++ b/cadence/contracts/FlowCreditMarket.cdc @@ -64,7 +64,6 @@ access(all) contract FlowCreditMarket { poolUUID: UInt64, targetHF: UFix128, warmupSec: UInt64, - protocolFeeBps: UInt16 ) access(all) event LiquidationsPaused( @@ -339,7 +338,6 @@ access(all) contract FlowCreditMarket { access(all) let warmupSec: UInt64 access(all) let lastUnpausedAt: UInt64? access(all) let triggerHF: UFix128 - access(all) let protocolFeeBps: UInt16 init( targetHF: UFix128, @@ -347,14 +345,12 @@ access(all) contract FlowCreditMarket { warmupSec: UInt64, lastUnpausedAt: UInt64?, triggerHF: UFix128, - protocolFeeBps: UInt16 ) { self.targetHF = targetHF self.paused = paused self.warmupSec = warmupSec self.lastUnpausedAt = lastUnpausedAt self.triggerHF = triggerHF - self.protocolFeeBps = protocolFeeBps } } @@ -1093,14 +1089,16 @@ access(all) contract FlowCreditMarket { access(EImplementation) var version: UInt64 /// Liquidation target health and controls (global) - /// TODO(jord): This is the desired health factor following liquidation? - access(self) var liquidationTargetHF: UFix128 // e24 fixed-point, e.g., 1.05e24 + /// The target health factor when liquidating a position, which limits how much collateral can be liquidated. + /// After a liquidation, the position's health factor must be less than or equal to this target value. + access(self) var liquidationTargetHF: UFix128 + /// Whether liquidations are currently paused access(self) var liquidationsPaused: Bool + /// Period (s) following liquidation unpause in which liquidations are still not allowed access(self) var liquidationWarmupSec: UInt64 + /// Time this pool most recently had liquidations paused access(self) var lastUnpausedAt: UInt64? - // TODO(jord): remove this - access(self) var protocolLiquidationFeeBps: UInt16 /// Allowlist of permitted DeFiActions Swapper types for DEX liquidations // TODO(jord): currently we store an allow-list of swapper types, but @@ -1147,7 +1145,6 @@ access(all) contract FlowCreditMarket { self.liquidationsPaused = false self.liquidationWarmupSec = 300 self.lastUnpausedAt = nil - self.protocolLiquidationFeeBps = 0 self.allowedSwapperTypes = {} self.dex = nil self.dexOracleDeviationBps = UInt16(300) // 3% default @@ -1194,7 +1191,6 @@ access(all) contract FlowCreditMarket { warmupSec: self.liquidationWarmupSec, lastUnpausedAt: self.lastUnpausedAt, triggerHF: 1.0, - protocolFeeBps: self.protocolLiquidationFeeBps ) } @@ -1409,6 +1405,8 @@ access(all) contract FlowCreditMarket { pre { // debt, collateral are both supported tokens // repaymentSource has sufficient balance + // liquidationsPaused is false + // liquidation warmup? } post { // health factor should be <= target @@ -1504,7 +1502,7 @@ access(all) contract FlowCreditMarket { return <-seizedCollateral } - + /// Returns the quantity of funds of a specified token which would need to be deposited /// in order to bring the position to the target health /// assuming we also withdraw a specified amount of another token. @@ -2443,11 +2441,9 @@ access(all) contract FlowCreditMarket { access(EGovernance) fun setLiquidationParams( targetHF: UFix128?, warmupSec: UInt64?, - protocolFeeBps: UInt16? ) { var newTarget = self.liquidationTargetHF var newWarmup = self.liquidationWarmupSec - var newProtocolFee = self.protocolLiquidationFeeBps if let targetHF = targetHF { assert( targetHF > 1.0, @@ -2460,15 +2456,11 @@ access(all) contract FlowCreditMarket { self.liquidationWarmupSec = warmupSec newWarmup = warmupSec } - if let protocolFeeBps = protocolFeeBps { - self.protocolLiquidationFeeBps = protocolFeeBps - newProtocolFee = protocolFeeBps - } + emit LiquidationParamsUpdated( poolUUID: self.uuid, targetHF: newTarget, warmupSec: newWarmup, - protocolFeeBps: newProtocolFee ) } From 6178e58914bf1660ad46e0fed7f64144984b6d64 Mon Sep 17 00:00:00 2001 From: Jordan Schalm Date: Wed, 7 Jan 2026 14:33:12 -0800 Subject: [PATCH 15/34] remove liqudiation quote, update docs --- cadence/contracts/FlowCreditMarket.cdc | 41 ++++---------------------- 1 file changed, 5 insertions(+), 36 deletions(-) diff --git a/cadence/contracts/FlowCreditMarket.cdc b/cadence/contracts/FlowCreditMarket.cdc index 7131079..7e26e68 100644 --- a/cadence/contracts/FlowCreditMarket.cdc +++ b/cadence/contracts/FlowCreditMarket.cdc @@ -300,18 +300,15 @@ access(all) contract FlowCreditMarket { /// BalanceSheet /// /// An struct containing a position's overview in terms of its effective collateral and debt - /// as well as its current health + /// as well as its current health. access(all) struct BalanceSheet { - /// A position's withdrawable value based on collateral deposits - /// against the Pool's collateral and borrow factors. - /// TODO(jord): I think this is "withdrawable value" only if effectiveDebt==0 - /// Denominated in MOET. + /// Effective collateral is a normalized valuation of collateral deposited into this position, denominated in $. + /// In combination with effective debt, this determines how much additional debt can be taken out by this position. access(all) let effectiveCollateral: UFix128 - /// A position's withdrawn value based on withdrawals - /// against the Pool's collateral and borrow factors - /// Denominated in MOET. + /// Effective debt is a normalized valuation of debt withdrawn against this position, denominated in $. + /// In combination with effective collateral, this determines how much additional debt can be taken out by this position. access(all) let effectiveDebt: UFix128 /// The health of the related position @@ -332,7 +329,6 @@ access(all) contract FlowCreditMarket { /// Liquidation parameters view (global) access(all) struct LiquidationParamsView { - /// Target health factor access(all) let targetHF: UFix128 access(all) let paused: Bool access(all) let warmupSec: UInt64 @@ -354,32 +350,6 @@ access(all) contract FlowCreditMarket { } } - /// A quote generated during the liquidation process. - /// It describes the terms of liquidation for a potential liquidator. - access(all) struct LiquidationQuote { - /// Amount the liquidator must repay, denominated in MOET - // TODO(jord): I think it is MOET, not sure - access(all) let requiredRepay: UFix64 - /// The type of collateral the liquidator may seize in exchange for repayment. - access(all) let seizeType: Type - /// The amount of collateral the liquidator may seize in exchange for repayment. - access(all) let seizeAmount: UFix64 - /// The new health factor after this liquidation occurs. - access(all) let newHF: UFix128 - - init( - requiredRepay: UFix64, - seizeType: Type, - seizeAmount: UFix64, - newHF: UFix128 - ) { - self.requiredRepay = requiredRepay - self.seizeType = seizeType - self.seizeAmount = seizeAmount - self.newHF = newHF - } - } - /// Entitlement mapping enabling authorized references on nested resources within InternalPosition access(all) entitlement mapping ImplementationUpdates { EImplementation -> Mutate @@ -392,7 +362,6 @@ access(all) contract FlowCreditMarket { access(all) resource InternalPosition { /// The target health of the position - /// TODO(jord): Is this user-defined or protocol-defined? access(EImplementation) var targetHealth: UFix128 /// The minimum health of the position, below which a position is considered undercollateralized From b03dfadb48bfca27a51c837b3b9522f543721b3f Mon Sep 17 00:00:00 2001 From: Jordan Schalm Date: Thu, 8 Jan 2026 13:18:29 -0800 Subject: [PATCH 16/34] update mock dex swapper to support SwapperProvider --- cadence/contracts/mocks/MockDexSwapper.cdc | 31 ++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/cadence/contracts/mocks/MockDexSwapper.cdc b/cadence/contracts/mocks/MockDexSwapper.cdc index 7b88fa0..36af2a1 100644 --- a/cadence/contracts/mocks/MockDexSwapper.cdc +++ b/cadence/contracts/mocks/MockDexSwapper.cdc @@ -3,11 +3,15 @@ import "FungibleToken" import "DeFiActions" import "DeFiActionsUtils" +import "FlowCreditMarket" /// TEST-ONLY mock swapper that withdraws output from a user-provided Vault capability. /// Do NOT use in production. access(all) contract MockDexSwapper { + /// inType -> outType -> Swapper + access(contract) let swappers: {Type: {Type: Swapper}} + access(all) struct BasicQuote : DeFiActions.Quote { access(all) let inType: Type access(all) let outType: Type @@ -86,6 +90,33 @@ access(all) contract MockDexSwapper { access(contract) view fun copyID(): DeFiActions.UniqueIdentifier? { return self.uniqueID } access(contract) fun setID(_ id: DeFiActions.UniqueIdentifier?) { self.uniqueID = id } } + + /// Adds the given swapper to the contract, overwriting any previously added swapper with the same in/out type. + /// After addition, will be returned by SwapperProvider.getSwapper. + access(all) fun addSwapper(swapper: Swapper) { + if let swappersByInType = self.swappers[swapper.inType()] { + swappersByInType[swapper.outType()] = swapper + self.swappers[swapper.inType()] = swappersByInType + } else { + self.swappers[swapper.inType()] = {swapper.outType(): swapper} + } + } + + /// Provides access to the set of swappers stored in this mock contract. + /// Tests can instantiate a pool with an instance of SwapperProvider, + /// then control the DEX behaviour with addSwapper. + access(all) struct SwapperProvider : FlowCreditMarket.SwapperProvider { + access(all) fun getSwapper(inType: Type, outType: Type): {DeFiActions.Swapper}? { + if let swappersForInType = MockDexSwapper.swappers[inType] { + return swappersForInType[outType] + } + return nil + } + } + + init() { + self.swappers = {} + } } From cd7c2e3a568730506f2f0cc919f58986c70854f6 Mon Sep 17 00:00:00 2001 From: Jordan Schalm Date: Thu, 8 Jan 2026 14:00:56 -0800 Subject: [PATCH 17/34] add tx for manual liquidation at this commit, failing on assert(initialHealth >= 1.0, message: "Cannot liquidate healthy position: \(initialHealth)>1") --- cadence/tests/liquidation_phase1_test.cdc | 544 +++++++++--------- cadence/tests/liquidation_phase2_dex_test.cdc | 144 ++--- .../liquidate_repay_for_seize.cdc | 87 --- .../pool-management/manual_liquidation.cdc | 64 +++ 4 files changed, 403 insertions(+), 436 deletions(-) delete mode 100644 cadence/transactions/flow-credit-market/pool-management/liquidate_repay_for_seize.cdc create mode 100644 cadence/transactions/flow-credit-market/pool-management/manual_liquidation.cdc diff --git a/cadence/tests/liquidation_phase1_test.cdc b/cadence/tests/liquidation_phase1_test.cdc index 052dd4d..6fe2d4f 100644 --- a/cadence/tests/liquidation_phase1_test.cdc +++ b/cadence/tests/liquidation_phase1_test.cdc @@ -67,28 +67,17 @@ fun test_liquidation_phase1_quote_and_execute() { let hAfterPriceUF = FlowCreditMarketMath.toUFix64Round(hAfterPrice) log("[LIQ] Health after price drop: raw=\(hAfterPrice), approx=\(hAfterPriceUF)") - // quote liquidation - let quoteRes = _executeScript( - "../scripts/flow-credit-market/quote_liquidation.cdc", - [0 as UInt64, Type<@MOET.Vault>().identifier, flowTokenIdentifier] - ) - Test.expect(quoteRes, Test.beSucceeded()) - let quote = quoteRes.returnValue as! FlowCreditMarket.LiquidationQuote - log("[LIQ] Quote: requiredRepay=\(quote.requiredRepay) seizeAmount=\(quote.seizeAmount) newHF=\(quote.newHF)") - Test.assert(quote.requiredRepay > 0.0) - Test.assert(quote.seizeAmount > 0.0) - // execute liquidation let liquidator = Test.createAccount() setupMoetVault(liquidator, beFailed: false) - _executeTransaction("../transactions/moet/mint_moet.cdc", [liquidator.address, quote.requiredRepay + 1.0], Test.getAccount(0x0000000000000007)) + _executeTransaction("../transactions/moet/mint_moet.cdc", [liquidator.address, 1000.0], Test.getAccount(0x0000000000000007)) let liqBalance = getBalance(address: liquidator.address, vaultPublicPath: /public/moetBalance) ?? 0.0 log("Liquidator MOET balance after mint: \(liqBalance)") let liqRes = _executeTransaction( - "../transactions/flow-credit-market/pool-management/liquidate_repay_for_seize.cdc", - [pid, Type<@MOET.Vault>().identifier, flowTokenIdentifier, quote.requiredRepay + 1.0, 0.0], + "../transactions/flow-credit-market/pool-management/manual_liquidation.cdc", + [pid, Type<@MOET.Vault>().identifier, flowTokenIdentifier, 1.0, 10.0], liquidator ) Test.expect(liqRes, Test.beSucceeded()) @@ -98,276 +87,277 @@ fun test_liquidation_phase1_quote_and_execute() { let hAfterLiqUF = FlowCreditMarketMath.toUFix64Round(hAfterLiq) log("[LIQ] Health after liquidation: raw=\(hAfterLiq), approx=\(hAfterLiqUF)") + // TODO: re-add these // Assert final health ≈ target - let targetHF: UFix128 = 1.05 - let tolerance: UFix128 = 0.00001 - Test.assert(hAfterLiq >= targetHF - tolerance && hAfterLiq <= targetHF + tolerance, message: "Post-liquidation health \(hAfterLiqUF) not at target 1.05") + // let targetHF: UFix128 = 1.05 + // let tolerance: UFix128 = 0.00001 + // Test.assert(hAfterLiq >= targetHF - tolerance && hAfterLiq <= targetHF + tolerance, message: "Post-liquidation health \(hAfterLiqUF) not at target 1.05") // Assert quoted newHF matches actual - Test.assert(quote.newHF >= targetHF - tolerance && quote.newHF <= targetHF + tolerance, message: "Quoted newHF not at target") + // Test.assert(quote.newHF >= targetHF - tolerance && quote.newHF <= targetHF + tolerance, message: "Quoted newHF not at target") - let detailsAfter = getPositionDetails(pid: pid, beFailed: false) - Test.assert(detailsAfter.health >= targetHF - tolerance, message: "Health not restored") + // let detailsAfter = getPositionDetails(pid: pid, beFailed: false) + // Test.assert(detailsAfter.health >= targetHF - tolerance, message: "Health not restored") } // DEX liquidation tests moved to liquidation_phase2_dex_test.cdc -access(all) -fun test_liquidation_insolvency() { - safeReset() - let pid: UInt64 = 0 - - let user = Test.createAccount() - setupMoetVault(user, beFailed: false) - transferFlowTokens(to: user, amount: 1000.0) - - let openRes = _executeTransaction( - "./transactions/mock-flow-credit-market-consumer/create_wrapped_position.cdc", - [1000.0, /storage/flowTokenVault, true], - user - ) - Test.expect(openRes, Test.beSucceeded()) - - // Severe undercollateralization (insolvent, but liquidation improves HF) - setMockOraclePrice(signer: Test.getAccount(0x0000000000000007), forTokenIdentifier: flowTokenIdentifier, price: 0.6) - let hAfter = getPositionHealth(pid: pid, beFailed: false) - Test.assert(FlowCreditMarketMath.toUFix64Round(hAfter) < 1.0) - - // Quote should suggest partial repay/seize that improves HF to max possible < target - let quoteRes = _executeScript( - "../scripts/flow-credit-market/quote_liquidation.cdc", - [pid, Type<@MOET.Vault>().identifier, flowTokenIdentifier] - ) - Test.expect(quoteRes, Test.beSucceeded()) - let quote = quoteRes.returnValue as! FlowCreditMarket.LiquidationQuote - if quote.requiredRepay == 0.0 { - // In deep insolvency with liquidation bonus, keeper repay-for-seize can worsen HF; expect no keeper quote - Test.assert(quote.seizeAmount == 0.0, message: "Expected zero seize when repay is zero") - return - } - Test.assert(quote.seizeAmount > 0.0, message: "Expected positive seizeAmount") - Test.assert(quote.newHF > hAfter && quote.newHF < 1.0) - - // Execute and assert improvement, HF < target - let keeper = Test.createAccount() - setupMoetVault(keeper, beFailed: false) - _executeTransaction("../transactions/moet/mint_moet.cdc", [keeper.address, quote.requiredRepay + 0.00000001], Test.getAccount(0x0000000000000007)) - - let liqRes = _executeTransaction( - "../transactions/flow-credit-market/pool-management/liquidate_repay_for_seize.cdc", - [pid, Type<@MOET.Vault>().identifier, flowTokenIdentifier, quote.requiredRepay + 0.00000001, 0.0], - keeper - ) - Test.expect(liqRes, Test.beSucceeded()) - - // Health should be max (zero debt left after partial repay) - let hFinal = getPositionHealth(pid: pid, beFailed: false) - let hFinalUF = FlowCreditMarketMath.toUFix64Round(hFinal) - log("hFinal: \(FlowCreditMarketMath.toUFix64Round(hFinal)), hAfter: \(FlowCreditMarketMath.toUFix64Round(hAfter))") - Test.assert(hFinal > hAfter, message: "Health not improved") - Test.assert(hFinalUF <= 1.05, message: "Insolvent HF exceeded target") -} - -access(all) -fun test_multi_liquidation() { - safeReset() - let pid: UInt64 = 0 - - let user = Test.createAccount() - setupMoetVault(user, beFailed: false) - transferFlowTokens(to: user, amount: 1000.0) - - _executeTransaction( - "./transactions/mock-flow-credit-market-consumer/create_wrapped_position.cdc", - [1000.0, /storage/flowTokenVault, true], - user - ) - - // Initial undercollateralization - setMockOraclePrice(signer: Test.getAccount(0x0000000000000007), forTokenIdentifier: flowTokenIdentifier, price: 0.7) - let hInitial = getPositionHealth(pid: pid, beFailed: false) - Test.assert(FlowCreditMarketMath.toUFix64Round(hInitial) < 1.0) - - // First liquidation - let quote1Res = _executeScript( - "../scripts/flow-credit-market/quote_liquidation.cdc", - [pid, Type<@MOET.Vault>().identifier, flowTokenIdentifier] - ) - let quote1 = quote1Res.returnValue as! FlowCreditMarket.LiquidationQuote - - let keeper1 = Test.createAccount() - setupMoetVault(keeper1, beFailed: false) - _executeTransaction("../transactions/moet/mint_moet.cdc", [keeper1.address, quote1.requiredRepay], Test.getAccount(0x0000000000000007)) - - _executeTransaction( - "../transactions/flow-credit-market/pool-management/liquidate_repay_for_seize.cdc", - [pid, Type<@MOET.Vault>().identifier, flowTokenIdentifier, quote1.requiredRepay, 0.0], - keeper1 - ) - - let hAfter1 = getPositionHealth(pid: pid, beFailed: false) - let targetHF: UFix128 = 1.05 - // Slightly relax tolerance for second liquidation to account for rounding across sequential updates - let tolerance: UFix128 = 0.00002 - Test.assert(hAfter1 >= targetHF - tolerance, message: "First liquidation did not reach target") - - // Drop price further for second liquidation - setMockOraclePrice(signer: Test.getAccount(0x0000000000000007), forTokenIdentifier: flowTokenIdentifier, price: 0.6) - - let hAfterDrop = getPositionHealth(pid: pid, beFailed: false) - Test.assert(FlowCreditMarketMath.toUFix64Round(hAfterDrop) < 1.0) - - // Second liquidation - let quote2Res = _executeScript( - "../scripts/flow-credit-market/quote_liquidation.cdc", - [pid, Type<@MOET.Vault>().identifier, flowTokenIdentifier] - ) - let quote2 = quote2Res.returnValue as! FlowCreditMarket.LiquidationQuote - - let keeper2 = Test.createAccount() - setupMoetVault(keeper2, beFailed: false) - _executeTransaction("../transactions/moet/mint_moet.cdc", [keeper2.address, quote2.requiredRepay + 0.00000001], Test.getAccount(0x0000000000000007)) - - _executeTransaction( - "../transactions/flow-credit-market/pool-management/liquidate_repay_for_seize.cdc", - [pid, Type<@MOET.Vault>().identifier, flowTokenIdentifier, quote2.requiredRepay + 0.00000001, 0.0], - keeper2 - ) - - let hFinal = getPositionHealth(pid: pid, beFailed: false) - log("[LIQ][TEST] Second liquidation hFinal UF=\(FlowCreditMarketMath.toUFix64Round(hFinal)) raw=\(hFinal)") - Test.assert(hFinal >= targetHF - tolerance, message: "Second liquidation did not reach target") -} - -access(all) -fun test_liquidation_overpay_attempt() { - safeReset() - let pid: UInt64 = 0 - - let user = Test.createAccount() - setupMoetVault(user, beFailed: false) - transferFlowTokens(to: user, amount: 1000.0) - - let openRes = _executeTransaction( - "./transactions/mock-flow-credit-market-consumer/create_wrapped_position.cdc", - [1000.0, /storage/flowTokenVault, true], - user - ) - Test.expect(openRes, Test.beSucceeded()) - - setMockOraclePrice(signer: Test.getAccount(0x0000000000000007), forTokenIdentifier: flowTokenIdentifier, price: 0.7) - - let quoteRes = _executeScript( - "../scripts/flow-credit-market/quote_liquidation.cdc", - [0 as UInt64, Type<@MOET.Vault>().identifier, flowTokenIdentifier] - ) - let quote = quoteRes.returnValue as! FlowCreditMarket.LiquidationQuote - if quote.requiredRepay == 0.0 { - // Near-threshold rounding case may produce zero-step; nothing to liquidate - return - } - - let liquidator = Test.createAccount() - setupMoetVault(liquidator, beFailed: false) - let overpayAmount = quote.requiredRepay + 10.0 - _executeTransaction("../transactions/moet/mint_moet.cdc", [liquidator.address, overpayAmount], Test.getAccount(0x0000000000000007)) - - let balanceBefore = getBalance(address: liquidator.address, vaultPublicPath: MOET.ReceiverPublicPath) ?? 0.0 - let collBalanceBefore = getBalance(address: liquidator.address, vaultPublicPath: /public/flowTokenReceiver) ?? 0.0 - - let liqRes = _executeTransaction( - "../transactions/flow-credit-market/pool-management/liquidate_repay_for_seize.cdc", - [pid, Type<@MOET.Vault>().identifier, flowTokenIdentifier, overpayAmount, 0.0], - liquidator - ) - Test.expect(liqRes, Test.beSucceeded()) - - let balanceAfter = getBalance(address: liquidator.address, vaultPublicPath: MOET.ReceiverPublicPath) ?? 0.0 - let collBalanceAfter = getBalance(address: liquidator.address, vaultPublicPath: /public/flowTokenReceiver) ?? 0.0 - - Test.assert(balanceAfter == balanceBefore - quote.requiredRepay, message: "Actual repay not equal to requiredRepay") - Test.assert(collBalanceAfter == collBalanceBefore + quote.seizeAmount, message: "Seize amount changed") -} - -access(all) -fun test_liquidation_slippage_failure() { - safeReset() - let pid: UInt64 = 0 - - // Setup similar to first test - let user = Test.createAccount() - setupMoetVault(user, beFailed: false) - transferFlowTokens(to: user, amount: 1000.0) - - _executeTransaction( - "./transactions/mock-flow-credit-market-consumer/create_wrapped_position.cdc", - [1000.0, /storage/flowTokenVault, true], - user - ) - - setMockOraclePrice(signer: Test.getAccount(0x0000000000000007), forTokenIdentifier: flowTokenIdentifier, price: 0.7) - - let quoteRes = _executeScript( - "../scripts/flow-credit-market/quote_liquidation.cdc", - [pid, Type<@MOET.Vault>().identifier, flowTokenIdentifier] - ) - let quote = quoteRes.returnValue as! FlowCreditMarket.LiquidationQuote - - let liquidator = Test.createAccount() - setupMoetVault(liquidator, beFailed: false) - _executeTransaction("../transactions/moet/mint_moet.cdc", [liquidator.address, quote.requiredRepay], Test.getAccount(0x0000000000000007)) - - // max < required -> revert - let lowMaxRes = _executeTransaction( - "../transactions/flow-credit-market/pool-management/liquidate_repay_for_seize.cdc", - [pid, Type<@MOET.Vault>().identifier, flowTokenIdentifier, quote.requiredRepay - 0.00000001, 0.0], - liquidator - ) - Test.expect(lowMaxRes, Test.beFailed()) - - // min > seize -> revert - let highMinRes = _executeTransaction( - "../transactions/flow-credit-market/pool-management/liquidate_repay_for_seize.cdc", - [pid, Type<@MOET.Vault>().identifier, flowTokenIdentifier, quote.requiredRepay, quote.seizeAmount + 0.1], - liquidator - ) - Test.expect(highMinRes, Test.beFailed()) -} - - -access(all) -fun test_liquidation_healthy_zero_quote() { - safeReset() - let pid: UInt64 = 0 - - let user = Test.createAccount() - setupMoetVault(user, beFailed: false) - transferFlowTokens(to: user, amount: 1000.0) - - let openRes = _executeTransaction( - "./transactions/mock-flow-credit-market-consumer/create_wrapped_position.cdc", - [1000.0, /storage/flowTokenVault, true], - user - ) - Test.expect(openRes, Test.beSucceeded()) - - // Set price to make HF > 1.0 (healthy) - setMockOraclePrice(signer: Test.getAccount(0x0000000000000007), forTokenIdentifier: flowTokenIdentifier, price: 1.2) - - let h = getPositionHealth(pid: pid, beFailed: false) - let hUF = FlowCreditMarketMath.toUFix64Round(h) - Test.assert(hUF > 1.0, message: "Position not healthy") - - let quoteRes = _executeScript( - "../scripts/flow-credit-market/quote_liquidation.cdc", - [pid, Type<@MOET.Vault>().identifier, flowTokenIdentifier] - ) - Test.expect(quoteRes, Test.beSucceeded()) - let quote = quoteRes.returnValue as! FlowCreditMarket.LiquidationQuote - - Test.assert(quote.requiredRepay == 0.0, message: "Required repay not zero for healthy position") - Test.assert(quote.seizeAmount == 0.0, message: "Seize amount not zero for healthy position") - Test.assert(quote.newHF == h, message: "New HF not matching current health") -} +// access(all) +// fun test_liquidation_insolvency() { +// safeReset() +// let pid: UInt64 = 0 + +// let user = Test.createAccount() +// setupMoetVault(user, beFailed: false) +// transferFlowTokens(to: user, amount: 1000.0) + +// let openRes = _executeTransaction( +// "./transactions/mock-flow-credit-market-consumer/create_wrapped_position.cdc", +// [1000.0, /storage/flowTokenVault, true], +// user +// ) +// Test.expect(openRes, Test.beSucceeded()) + +// // Severe undercollateralization (insolvent, but liquidation improves HF) +// setMockOraclePrice(signer: Test.getAccount(0x0000000000000007), forTokenIdentifier: flowTokenIdentifier, price: 0.6) +// let hAfter = getPositionHealth(pid: pid, beFailed: false) +// Test.assert(FlowCreditMarketMath.toUFix64Round(hAfter) < 1.0) + +// // Quote should suggest partial repay/seize that improves HF to max possible < target +// let quoteRes = _executeScript( +// "../scripts/flow-credit-market/quote_liquidation.cdc", +// [pid, Type<@MOET.Vault>().identifier, flowTokenIdentifier] +// ) +// Test.expect(quoteRes, Test.beSucceeded()) +// let quote = quoteRes.returnValue as! FlowCreditMarket.LiquidationQuote +// if quote.requiredRepay == 0.0 { +// // In deep insolvency with liquidation bonus, keeper repay-for-seize can worsen HF; expect no keeper quote +// Test.assert(quote.seizeAmount == 0.0, message: "Expected zero seize when repay is zero") +// return +// } +// Test.assert(quote.seizeAmount > 0.0, message: "Expected positive seizeAmount") +// Test.assert(quote.newHF > hAfter && quote.newHF < 1.0) + +// // Execute and assert improvement, HF < target +// let keeper = Test.createAccount() +// setupMoetVault(keeper, beFailed: false) +// _executeTransaction("../transactions/moet/mint_moet.cdc", [keeper.address, quote.requiredRepay + 0.00000001], Test.getAccount(0x0000000000000007)) + +// let liqRes = _executeTransaction( +// "../transactions/flow-credit-market/pool-management/liquidate_repay_for_seize.cdc", +// [pid, Type<@MOET.Vault>().identifier, flowTokenIdentifier, quote.requiredRepay + 0.00000001, 0.0], +// keeper +// ) +// Test.expect(liqRes, Test.beSucceeded()) + +// // Health should be max (zero debt left after partial repay) +// let hFinal = getPositionHealth(pid: pid, beFailed: false) +// let hFinalUF = FlowCreditMarketMath.toUFix64Round(hFinal) +// log("hFinal: \(FlowCreditMarketMath.toUFix64Round(hFinal)), hAfter: \(FlowCreditMarketMath.toUFix64Round(hAfter))") +// Test.assert(hFinal > hAfter, message: "Health not improved") +// Test.assert(hFinalUF <= 1.05, message: "Insolvent HF exceeded target") +// } + +// access(all) +// fun test_multi_liquidation() { +// safeReset() +// let pid: UInt64 = 0 + +// let user = Test.createAccount() +// setupMoetVault(user, beFailed: false) +// transferFlowTokens(to: user, amount: 1000.0) + +// _executeTransaction( +// "./transactions/mock-flow-credit-market-consumer/create_wrapped_position.cdc", +// [1000.0, /storage/flowTokenVault, true], +// user +// ) + +// // Initial undercollateralization +// setMockOraclePrice(signer: Test.getAccount(0x0000000000000007), forTokenIdentifier: flowTokenIdentifier, price: 0.7) +// let hInitial = getPositionHealth(pid: pid, beFailed: false) +// Test.assert(FlowCreditMarketMath.toUFix64Round(hInitial) < 1.0) + +// // First liquidation +// let quote1Res = _executeScript( +// "../scripts/flow-credit-market/quote_liquidation.cdc", +// [pid, Type<@MOET.Vault>().identifier, flowTokenIdentifier] +// ) +// let quote1 = quote1Res.returnValue as! FlowCreditMarket.LiquidationQuote + +// let keeper1 = Test.createAccount() +// setupMoetVault(keeper1, beFailed: false) +// _executeTransaction("../transactions/moet/mint_moet.cdc", [keeper1.address, quote1.requiredRepay], Test.getAccount(0x0000000000000007)) + +// _executeTransaction( +// "../transactions/flow-credit-market/pool-management/liquidate_repay_for_seize.cdc", +// [pid, Type<@MOET.Vault>().identifier, flowTokenIdentifier, quote1.requiredRepay, 0.0], +// keeper1 +// ) + +// let hAfter1 = getPositionHealth(pid: pid, beFailed: false) +// let targetHF: UFix128 = 1.05 +// // Slightly relax tolerance for second liquidation to account for rounding across sequential updates +// let tolerance: UFix128 = 0.00002 +// Test.assert(hAfter1 >= targetHF - tolerance, message: "First liquidation did not reach target") + +// // Drop price further for second liquidation +// setMockOraclePrice(signer: Test.getAccount(0x0000000000000007), forTokenIdentifier: flowTokenIdentifier, price: 0.6) + +// let hAfterDrop = getPositionHealth(pid: pid, beFailed: false) +// Test.assert(FlowCreditMarketMath.toUFix64Round(hAfterDrop) < 1.0) + +// // Second liquidation +// let quote2Res = _executeScript( +// "../scripts/flow-credit-market/quote_liquidation.cdc", +// [pid, Type<@MOET.Vault>().identifier, flowTokenIdentifier] +// ) +// let quote2 = quote2Res.returnValue as! FlowCreditMarket.LiquidationQuote + +// let keeper2 = Test.createAccount() +// setupMoetVault(keeper2, beFailed: false) +// _executeTransaction("../transactions/moet/mint_moet.cdc", [keeper2.address, quote2.requiredRepay + 0.00000001], Test.getAccount(0x0000000000000007)) + +// _executeTransaction( +// "../transactions/flow-credit-market/pool-management/liquidate_repay_for_seize.cdc", +// [pid, Type<@MOET.Vault>().identifier, flowTokenIdentifier, quote2.requiredRepay + 0.00000001, 0.0], +// keeper2 +// ) + +// let hFinal = getPositionHealth(pid: pid, beFailed: false) +// log("[LIQ][TEST] Second liquidation hFinal UF=\(FlowCreditMarketMath.toUFix64Round(hFinal)) raw=\(hFinal)") +// Test.assert(hFinal >= targetHF - tolerance, message: "Second liquidation did not reach target") +// } + +// access(all) +// fun test_liquidation_overpay_attempt() { +// safeReset() +// let pid: UInt64 = 0 + +// let user = Test.createAccount() +// setupMoetVault(user, beFailed: false) +// transferFlowTokens(to: user, amount: 1000.0) + +// let openRes = _executeTransaction( +// "./transactions/mock-flow-credit-market-consumer/create_wrapped_position.cdc", +// [1000.0, /storage/flowTokenVault, true], +// user +// ) +// Test.expect(openRes, Test.beSucceeded()) + +// setMockOraclePrice(signer: Test.getAccount(0x0000000000000007), forTokenIdentifier: flowTokenIdentifier, price: 0.7) + +// let quoteRes = _executeScript( +// "../scripts/flow-credit-market/quote_liquidation.cdc", +// [0 as UInt64, Type<@MOET.Vault>().identifier, flowTokenIdentifier] +// ) +// let quote = quoteRes.returnValue as! FlowCreditMarket.LiquidationQuote +// if quote.requiredRepay == 0.0 { +// // Near-threshold rounding case may produce zero-step; nothing to liquidate +// return +// } + +// let liquidator = Test.createAccount() +// setupMoetVault(liquidator, beFailed: false) +// let overpayAmount = quote.requiredRepay + 10.0 +// _executeTransaction("../transactions/moet/mint_moet.cdc", [liquidator.address, overpayAmount], Test.getAccount(0x0000000000000007)) + +// let balanceBefore = getBalance(address: liquidator.address, vaultPublicPath: MOET.ReceiverPublicPath) ?? 0.0 +// let collBalanceBefore = getBalance(address: liquidator.address, vaultPublicPath: /public/flowTokenReceiver) ?? 0.0 + +// let liqRes = _executeTransaction( +// "../transactions/flow-credit-market/pool-management/liquidate_repay_for_seize.cdc", +// [pid, Type<@MOET.Vault>().identifier, flowTokenIdentifier, overpayAmount, 0.0], +// liquidator +// ) +// Test.expect(liqRes, Test.beSucceeded()) + +// let balanceAfter = getBalance(address: liquidator.address, vaultPublicPath: MOET.ReceiverPublicPath) ?? 0.0 +// let collBalanceAfter = getBalance(address: liquidator.address, vaultPublicPath: /public/flowTokenReceiver) ?? 0.0 + +// Test.assert(balanceAfter == balanceBefore - quote.requiredRepay, message: "Actual repay not equal to requiredRepay") +// Test.assert(collBalanceAfter == collBalanceBefore + quote.seizeAmount, message: "Seize amount changed") +// } + +// access(all) +// fun test_liquidation_slippage_failure() { +// safeReset() +// let pid: UInt64 = 0 + +// // Setup similar to first test +// let user = Test.createAccount() +// setupMoetVault(user, beFailed: false) +// transferFlowTokens(to: user, amount: 1000.0) + +// _executeTransaction( +// "./transactions/mock-flow-credit-market-consumer/create_wrapped_position.cdc", +// [1000.0, /storage/flowTokenVault, true], +// user +// ) + +// setMockOraclePrice(signer: Test.getAccount(0x0000000000000007), forTokenIdentifier: flowTokenIdentifier, price: 0.7) + +// let quoteRes = _executeScript( +// "../scripts/flow-credit-market/quote_liquidation.cdc", +// [pid, Type<@MOET.Vault>().identifier, flowTokenIdentifier] +// ) +// let quote = quoteRes.returnValue as! FlowCreditMarket.LiquidationQuote + +// let liquidator = Test.createAccount() +// setupMoetVault(liquidator, beFailed: false) +// _executeTransaction("../transactions/moet/mint_moet.cdc", [liquidator.address, quote.requiredRepay], Test.getAccount(0x0000000000000007)) + +// // max < required -> revert +// let lowMaxRes = _executeTransaction( +// "../transactions/flow-credit-market/pool-management/liquidate_repay_for_seize.cdc", +// [pid, Type<@MOET.Vault>().identifier, flowTokenIdentifier, quote.requiredRepay - 0.00000001, 0.0], +// liquidator +// ) +// Test.expect(lowMaxRes, Test.beFailed()) + +// // min > seize -> revert +// let highMinRes = _executeTransaction( +// "../transactions/flow-credit-market/pool-management/liquidate_repay_for_seize.cdc", +// [pid, Type<@MOET.Vault>().identifier, flowTokenIdentifier, quote.requiredRepay, quote.seizeAmount + 0.1], +// liquidator +// ) +// Test.expect(highMinRes, Test.beFailed()) +// } + + +// access(all) +// fun test_liquidation_healthy_zero_quote() { +// safeReset() +// let pid: UInt64 = 0 + +// let user = Test.createAccount() +// setupMoetVault(user, beFailed: false) +// transferFlowTokens(to: user, amount: 1000.0) + +// let openRes = _executeTransaction( +// "./transactions/mock-flow-credit-market-consumer/create_wrapped_position.cdc", +// [1000.0, /storage/flowTokenVault, true], +// user +// ) +// Test.expect(openRes, Test.beSucceeded()) + +// // Set price to make HF > 1.0 (healthy) +// setMockOraclePrice(signer: Test.getAccount(0x0000000000000007), forTokenIdentifier: flowTokenIdentifier, price: 1.2) + +// let h = getPositionHealth(pid: pid, beFailed: false) +// let hUF = FlowCreditMarketMath.toUFix64Round(h) +// Test.assert(hUF > 1.0, message: "Position not healthy") + +// let quoteRes = _executeScript( +// "../scripts/flow-credit-market/quote_liquidation.cdc", +// [pid, Type<@MOET.Vault>().identifier, flowTokenIdentifier] +// ) +// Test.expect(quoteRes, Test.beSucceeded()) +// let quote = quoteRes.returnValue as! FlowCreditMarket.LiquidationQuote + +// Test.assert(quote.requiredRepay == 0.0, message: "Required repay not zero for healthy position") +// Test.assert(quote.seizeAmount == 0.0, message: "Seize amount not zero for healthy position") +// Test.assert(quote.newHF == h, message: "New HF not matching current health") +// } // Time-based warmup enforcement test removed diff --git a/cadence/tests/liquidation_phase2_dex_test.cdc b/cadence/tests/liquidation_phase2_dex_test.cdc index 462c689..1c50387 100644 --- a/cadence/tests/liquidation_phase2_dex_test.cdc +++ b/cadence/tests/liquidation_phase2_dex_test.cdc @@ -1,86 +1,86 @@ -import Test -import BlockchainHelpers -import "test_helpers.cdc" +// import Test +// import BlockchainHelpers +// import "test_helpers.cdc" -import "FlowCreditMarket" -import "MOET" -import "FlowToken" -import "FlowCreditMarketMath" -import "MockDexSwapper" +// import "FlowCreditMarket" +// import "MOET" +// import "FlowToken" +// import "FlowCreditMarketMath" +// import "MockDexSwapper" -access(all) -fun setup() { - deployContracts() +// access(all) +// fun setup() { +// deployContracts() - let protocolAccount = Test.getAccount(0x0000000000000007) +// let protocolAccount = Test.getAccount(0x0000000000000007) - setMockOraclePrice(signer: protocolAccount, forTokenIdentifier: Type<@FlowToken.Vault>().identifier, price: 1.0) - createAndStorePool(signer: protocolAccount, defaultTokenIdentifier: Type<@MOET.Vault>().identifier, beFailed: false) - grantPoolCapToConsumer() - addSupportedTokenZeroRateCurve( - signer: protocolAccount, - tokenTypeIdentifier: Type<@FlowToken.Vault>().identifier, - collateralFactor: 0.8, - borrowFactor: 1.0, - depositRate: 1_000_000.0, - depositCapacityCap: 1_000_000.0 - ) -} +// setMockOraclePrice(signer: protocolAccount, forTokenIdentifier: Type<@FlowToken.Vault>().identifier, price: 1.0) +// createAndStorePool(signer: protocolAccount, defaultTokenIdentifier: Type<@MOET.Vault>().identifier, beFailed: false) +// grantPoolCapToConsumer() +// addSupportedTokenZeroRateCurve( +// signer: protocolAccount, +// tokenTypeIdentifier: Type<@FlowToken.Vault>().identifier, +// collateralFactor: 0.8, +// borrowFactor: 1.0, +// depositRate: 1_000_000.0, +// depositCapacityCap: 1_000_000.0 +// ) +// } -access(all) -fun test_liquidation_via_dex() { - let pid: UInt64 = 0 +// access(all) +// fun test_liquidation_via_dex() { +// let pid: UInt64 = 0 - // user setup - let user = Test.createAccount() - setupMoetVault(user, beFailed: false) - transferFlowTokens(to: user, amount: 1000.0) +// // user setup +// let user = Test.createAccount() +// setupMoetVault(user, beFailed: false) +// transferFlowTokens(to: user, amount: 1000.0) - _executeTransaction( - "./transactions/mock-flow-credit-market-consumer/create_wrapped_position.cdc", - [1000.0, /storage/flowTokenVault, true], - user - ) +// _executeTransaction( +// "./transactions/mock-flow-credit-market-consumer/create_wrapped_position.cdc", +// [1000.0, /storage/flowTokenVault, true], +// user +// ) - // make unhealthy - setMockOraclePrice(signer: Test.getAccount(0x0000000000000007), forTokenIdentifier: Type<@FlowToken.Vault>().identifier, price: 0.7) - let h0 = getPositionHealth(pid: pid, beFailed: false) - Test.assert(FlowCreditMarketMath.toUFix64Round(h0) < 1.0) +// // make unhealthy +// setMockOraclePrice(signer: Test.getAccount(0x0000000000000007), forTokenIdentifier: Type<@FlowToken.Vault>().identifier, price: 0.7) +// let h0 = getPositionHealth(pid: pid, beFailed: false) +// Test.assert(FlowCreditMarketMath.toUFix64Round(h0) < 1.0) - // perform liquidation via mock dex using signer as protocol - let protocol = Test.getAccount(0x0000000000000007) - // allowlist MockDexSwapper - let swapperTypeId = Type().identifier - let allowTx = Test.Transaction( - code: Test.readFile("../transactions/flow-credit-market/pool-governance/set_dex_liquidation_config.cdc"), - authorizers: [protocol.address], - signers: [protocol], - arguments: [nil, [swapperTypeId], nil, nil, nil] - ) - let allowRes = Test.executeTransaction(allowTx) - Test.expect(allowRes, Test.beSucceeded()) - // ensure protocol MOET liquidity for DEX swapper source - setupMoetVault(protocol, beFailed: false) - mintMoet(signer: protocol, to: protocol.address, amount: 1_000_000.0, beFailed: false) - let txRes = _executeTransaction( - "../transactions/flow-credit-market/pool-management/liquidate_via_mock_dex.cdc", - [pid, Type<@MOET.Vault>(), Type<@FlowToken.Vault>(), 1000.0, 0.0, 1.42857143], - protocol - ) - Test.expect(txRes, Test.beSucceeded()) +// // perform liquidation via mock dex using signer as protocol +// let protocol = Test.getAccount(0x0000000000000007) +// // allowlist MockDexSwapper +// let swapperTypeId = Type().identifier +// let allowTx = Test.Transaction( +// code: Test.readFile("../transactions/flow-credit-market/pool-governance/set_dex_liquidation_config.cdc"), +// authorizers: [protocol.address], +// signers: [protocol], +// arguments: [nil, [swapperTypeId], nil, nil, nil] +// ) +// let allowRes = Test.executeTransaction(allowTx) +// Test.expect(allowRes, Test.beSucceeded()) +// // ensure protocol MOET liquidity for DEX swapper source +// setupMoetVault(protocol, beFailed: false) +// mintMoet(signer: protocol, to: protocol.address, amount: 1_000_000.0, beFailed: false) +// let txRes = _executeTransaction( +// "../transactions/flow-credit-market/pool-management/liquidate_via_mock_dex.cdc", +// [pid, Type<@MOET.Vault>(), Type<@FlowToken.Vault>(), 1000.0, 0.0, 1.42857143], +// protocol +// ) +// Test.expect(txRes, Test.beSucceeded()) - // HF should improve to at/near target - let h1 = getPositionHealth(pid: pid, beFailed: false) - let target: UFix128 = 1.05 - let tol: UFix128 = 0.00001 - Test.assert(h1 >= target - tol) -} +// // HF should improve to at/near target +// let h1 = getPositionHealth(pid: pid, beFailed: false) +// let target: UFix128 = 1.05 +// let tol: UFix128 = 0.00001 +// Test.assert(h1 >= target - tol) +// } -access(all) -fun test_mockdex_quote_math_placeholder_noop() { - // Moved to dedicated file to avoid redeploy collisions in CI - Test.assert(true) -} +// access(all) +// fun test_mockdex_quote_math_placeholder_noop() { +// // Moved to dedicated file to avoid redeploy collisions in CI +// Test.assert(true) +// } diff --git a/cadence/transactions/flow-credit-market/pool-management/liquidate_repay_for_seize.cdc b/cadence/transactions/flow-credit-market/pool-management/liquidate_repay_for_seize.cdc deleted file mode 100644 index 8e40090..0000000 --- a/cadence/transactions/flow-credit-market/pool-management/liquidate_repay_for_seize.cdc +++ /dev/null @@ -1,87 +0,0 @@ -import "FungibleToken" -import "FlowToken" - -import "FlowCreditMarket" -import "MOET" - -/// Liquidate a position by repaying exactly the required amount to reach target HF and seizing collateral -/// debtVaultIdentifier: e.g., Type<@MOET.Vault>().identifier -/// seizeVaultIdentifier: e.g., Type<@FlowToken.Vault>().identifier -transaction(pid: UInt64, debtVaultIdentifier: String, seizeVaultIdentifier: String, maxRepayAmount: UFix64, minSeizeAmount: UFix64) { - let pool: &FlowCreditMarket.Pool - let receiver: &{FungibleToken.Receiver} - var refundReceiver: &{FungibleToken.Receiver}? - let debtType: Type - let seizeType: Type - let repay: @{FungibleToken.Vault} - - prepare(signer: auth(BorrowValue, SaveValue, IssueStorageCapabilityController, PublishCapability, UnpublishCapability) &Account) { - let protocolAddress = Type<@FlowCreditMarket.Pool>().address! - self.pool = getAccount(protocolAddress).capabilities.borrow<&FlowCreditMarket.Pool>(FlowCreditMarket.PoolPublicPath) - ?? panic("Could not borrow Pool at \(FlowCreditMarket.PoolPublicPath)") - - // Resolve types - self.debtType = CompositeType(debtVaultIdentifier) ?? panic("Invalid debtVaultIdentifier: \(debtVaultIdentifier)") - self.seizeType = CompositeType(seizeVaultIdentifier) ?? panic("Invalid seizeVaultIdentifier: \(seizeVaultIdentifier)") - - // Add refundReceiver setup - self.refundReceiver = nil - if self.debtType == Type<@MOET.Vault>() { - self.refundReceiver = signer.capabilities.borrow<&{FungibleToken.Receiver}>(MOET.ReceiverPublicPath) - } else if self.debtType == Type<@FlowToken.Vault>() { - self.refundReceiver = signer.capabilities.borrow<&{FungibleToken.Receiver}>(/public/flowTokenReceiver) - } - assert(self.refundReceiver != nil, message: "Missing refund receiver for debt type") - - // Quote - let quote = self.pool.quoteLiquidation(pid: pid, debtType: self.debtType, seizeType: self.seizeType) - assert(quote.requiredRepay > 0.0, message: "Nothing to liquidate") - assert(quote.seizeAmount >= minSeizeAmount, message: "Seize below minimum") - assert(maxRepayAmount >= quote.requiredRepay, message: "Max repay too low") - - // Withdraw maxRepayAmount - var tmpRepay: @{FungibleToken.Vault}? <- nil - if self.debtType == Type<@MOET.Vault>() { - let repayVaultRef = signer.storage.borrow(from: MOET.VaultStoragePath) - ?? panic("No MOET vault in storage") - assert(repayVaultRef.balance >= maxRepayAmount, message: "Insufficient MOET balance") - tmpRepay <-! repayVaultRef.withdraw(amount: maxRepayAmount) - } - if tmpRepay == nil && self.debtType == Type<@FlowToken.Vault>() { - let repayVaultRef = signer.storage.borrow(from: /storage/flowTokenVault) - ?? panic("No Flow vault in storage") - assert(repayVaultRef.balance >= maxRepayAmount, message: "Insufficient Flow balance") - tmpRepay <-! repayVaultRef.withdraw(amount: maxRepayAmount) - } - assert(tmpRepay != nil, message: "Unsupported debt token type for demo transaction") - self.repay <- tmpRepay! - - // Receiver for seized collateral (supports Flow or MOET in demo) - let flowRecv = signer.capabilities.borrow<&{FungibleToken.Receiver}>(/public/flowTokenReceiver) - let moetRecv = signer.capabilities.borrow<&{FungibleToken.Receiver}>(MOET.ReceiverPublicPath) - assert(flowRecv != nil || moetRecv != nil, message: "Missing receiver for seized tokens") - if flowRecv != nil { - self.receiver = flowRecv! - } else { - self.receiver = moetRecv! - } - } - - execute { - // Execute liquidation; get seized collateral vault - let result <- self.pool.liquidateRepayForSeize( - pid: pid, - debtType: self.debtType, - maxRepayAmount: maxRepayAmount, - seizeType: self.seizeType, - minSeizeAmount: minSeizeAmount, - from: <-self.repay - ) - let seized <- result.takeSeized() - let remainder <- result.takeRemainder() - destroy result - - self.receiver.deposit(from: <-seized) - self.refundReceiver!.deposit(from: <-remainder) - } -} diff --git a/cadence/transactions/flow-credit-market/pool-management/manual_liquidation.cdc b/cadence/transactions/flow-credit-market/pool-management/manual_liquidation.cdc new file mode 100644 index 0000000..c474755 --- /dev/null +++ b/cadence/transactions/flow-credit-market/pool-management/manual_liquidation.cdc @@ -0,0 +1,64 @@ +import "FungibleToken" +import "FlowToken" +import "FungibleTokenMetadataViews" +import "MetadataViews" + +import "FlowCreditMarket" +import "MOET" + +/// Attempt to liquidation a position by repaying `repayAmount`. +/// +/// debtVaultIdentifier: e.g., Type<@MOET.Vault>().identifier +/// seizeVaultIdentifier: e.g., Type<@FlowToken.Vault>().identifier +transaction(pid: UInt64, debtVaultIdentifier: String, seizeVaultIdentifier: String, seizeAmount: UFix64, repayAmount: UFix64) { + let pool: &FlowCreditMarket.Pool + let receiver: &{FungibleToken.Receiver} + let debtType: Type + let seizeType: Type + let repay: @{FungibleToken.Vault} + + prepare(signer: auth(BorrowValue, SaveValue, IssueStorageCapabilityController, PublishCapability, UnpublishCapability) &Account) { + let protocolAddress = Type<@FlowCreditMarket.Pool>().address! + self.pool = getAccount(protocolAddress).capabilities.borrow<&FlowCreditMarket.Pool>(FlowCreditMarket.PoolPublicPath) + ?? panic("Could not borrow Pool at \(FlowCreditMarket.PoolPublicPath)") + + // Resolve types + self.debtType = CompositeType(debtVaultIdentifier) ?? panic("Invalid debtVaultIdentifier: \(debtVaultIdentifier)") + self.seizeType = CompositeType(seizeVaultIdentifier) ?? panic("Invalid seizeVaultIdentifier: \(seizeVaultIdentifier)") + + // Get the path and type data for the provided token type identifier + let debtVaultData = MetadataViews.resolveContractViewFromTypeIdentifier( + resourceTypeIdentifier: debtVaultIdentifier, + viewType: Type() + ) as? FungibleTokenMetadataViews.FTVaultData + ?? panic("Could not construct valid FT type and view from identifier \(debtVaultIdentifier)") + + let seizeVaultData = MetadataViews.resolveContractViewFromTypeIdentifier( + resourceTypeIdentifier: seizeVaultIdentifier, + viewType: Type() + ) as? FungibleTokenMetadataViews.FTVaultData + ?? panic("Could not construct valid FT type and view from identifier \(seizeVaultIdentifier)") + + // Check if the service account has a vault for this token type at the correct storage path + let debtVaultRef = signer.storage.borrow(from: debtVaultData.storagePath) + ?? panic("no debt vault in storage at path \(debtVaultData.storagePath)") + assert(debtVaultRef.balance >= repayAmount, message: "Insufficient MOET balance") + self.repay <- debtVaultRef.withdraw(amount: repayAmount) + + let seizeVaultRef = signer.capabilities.borrow<&{FungibleToken.Receiver}>(seizeVaultData.receiverPath) + ?? panic("no seize receiver in storage at path \(seizeVaultData.receiverPath)") + self.receiver = seizeVaultRef + } + + execute { + let seizedVault <- self.pool.manualLiquidation( + pid: pid, + debtType: self.debtType, + seizeType: self.seizeType, + seizeAmount: seizeAmount, + repayment: <-self.repay + ) + + self.receiver.deposit(from: <-seizedVault) + } +} From af33646d0e7cba133f8f05199fd281a348d38587 Mon Sep 17 00:00:00 2001 From: Jordan Schalm Date: Thu, 8 Jan 2026 14:03:44 -0800 Subject: [PATCH 18/34] fix health assertion now we are failing on missing dex handle, as expected --- cadence/contracts/FlowCreditMarket.cdc | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cadence/contracts/FlowCreditMarket.cdc b/cadence/contracts/FlowCreditMarket.cdc index 7e26e68..02a4b0f 100644 --- a/cadence/contracts/FlowCreditMarket.cdc +++ b/cadence/contracts/FlowCreditMarket.cdc @@ -1384,7 +1384,7 @@ access(all) contract FlowCreditMarket { let positionView = self.buildPositionView(pid: pid) let balanceSheet = self._getUpdatedBalanceSheet(pid: pid) let initialHealth = balanceSheet.health - assert(initialHealth >= 1.0, message: "Cannot liquidate healthy position: \(initialHealth)>1") + assert(initialHealth < 1.0, message: "Cannot liquidate healthy position: \(initialHealth)>1") // Ensure liquidation amounts don't exceed position amounts let repayAmount = repayment.balance From 2c690ed900077beb3a32e53aa643db5b5c398eed Mon Sep 17 00:00:00 2001 From: Jordan Schalm Date: Thu, 8 Jan 2026 16:02:21 -0800 Subject: [PATCH 19/34] minimal working test --- cadence/contracts/FlowCreditMarket.cdc | 24 +++++++++++++++++-- cadence/tests/liquidation_phase1_test.cdc | 11 ++++++--- .../pool-management/manual_liquidation.cdc | 2 +- 3 files changed, 31 insertions(+), 6 deletions(-) diff --git a/cadence/contracts/FlowCreditMarket.cdc b/cadence/contracts/FlowCreditMarket.cdc index 02a4b0f..42eb199 100644 --- a/cadence/contracts/FlowCreditMarket.cdc +++ b/cadence/contracts/FlowCreditMarket.cdc @@ -1069,8 +1069,12 @@ access(all) contract FlowCreditMarket { /// Time this pool most recently had liquidations paused access(self) var lastUnpausedAt: UInt64? + // TODO(jord): figure out how to reference dex https://github.com/onflow/FlowCreditMarket/issues/94 + // - either need to redeploy contract to create new dex field + // - or need to revert to allowlist pattern and pass in swapper instances (I worry about security of this option) + // - also to make allowlist pattern work with automated liquidation, initiator of this automation will need actual handle on a dex in order to pass it to FCM + /// Allowlist of permitted DeFiActions Swapper types for DEX liquidations - // TODO(jord): currently we store an allow-list of swapper types, but access(self) var allowedSwapperTypes: {Type: Bool} access(self) var dex: {SwapperProvider}? @@ -1189,6 +1193,19 @@ access(all) contract FlowCreditMarket { return vaultRef?.balance ?? 0.0 } + /// Returns a reference to the reserve vault for the given type, if the token type is supported. + /// If no reserve vault exists yet, and the token type is supported, the reserve vault is created. + access(self) fun _borrowOrCreateReserveVault(type: Type): &{FungibleToken.Vault} { + pre { + self.isTokenSupported(tokenType: type) + } + if self.reserves[type] == nil { + self.reserves[type] <-! DeFiActionsUtils.getEmptyVault(type) + } + let vaultRef = &self.reserves[type] as auth(FungibleToken.Withdraw) &{FungibleToken.Vault}? + return vaultRef! + } + /// Returns a position's balance available for withdrawal of a given Vault type. /// Phase 0 refactor: compute via pure helpers using a PositionView and TokenSnapshot for the base path. /// When `pullFromTopUpSource` is true and a topUpSource exists, preserve deposit-assisted semantics. @@ -1413,6 +1430,8 @@ access(all) contract FlowCreditMarket { let postHealth = FlowCreditMarket.healthComputation(effectiveCollateral: Ce_post, effectiveDebt: De_post) assert(postHealth <= self.liquidationTargetHF, message: "Liquidation must not exceed target health: \(postHealth)>\(self.liquidationTargetHF)") + // TODO(jord): uncomment following when implementing SwapperProvider +/* // Compare the liquidation offer to liquidation via DEX. If the DEX would provide a better price, reject the offer. let swapper = self.dex!.getSwapper(inType: seizeType, outType: debtType)! // TODO: will revert if pair unsupported // Get a quote: "how much collateral do I need to give you to get `repayAmount` debt tokens" @@ -1428,6 +1447,7 @@ access(all) contract FlowCreditMarket { let Pcd_dex_oracle_diffBps = UInt16(Pcd_dex_oracle_diffPct * 10_000.0) // cannot overflow because Pcd_dex_oracle_diffPct<=1 assert(Pcd_dex_oracle_diffBps <= self.dexOracleDeviationBps, message: "Too large difference between dex/oracle prices diff=\(Pcd_dex_oracle_diffBps)bps") +*/ // Execute the liquidation return <- self._doLiquidation(pid: pid, repayment: <-repayment, debtType: debtType, seizeType: seizeType, seizeAmount: seizeAmount) @@ -1443,7 +1463,7 @@ access(all) contract FlowCreditMarket { let repayAmount = repayment.balance assert(repayment.getType() == debtType, message: "Vault type mismatch for repay") - let debtReserveRef = (&self.reserves[debtType] as auth(FungibleToken.Withdraw) &{FungibleToken.Vault}?)! + let debtReserveRef = self._borrowOrCreateReserveVault(type: debtType) debtReserveRef.deposit(from: <-repayment) // Reduce borrower's debt position by repayAmount diff --git a/cadence/tests/liquidation_phase1_test.cdc b/cadence/tests/liquidation_phase1_test.cdc index 6fe2d4f..d4182e4 100644 --- a/cadence/tests/liquidation_phase1_test.cdc +++ b/cadence/tests/liquidation_phase1_test.cdc @@ -49,6 +49,7 @@ fun test_liquidation_phase1_quote_and_execute() { transferFlowTokens(to: user, amount: 1000.0) // open wrapped position and deposit via existing helper txs + // debt is MOET, collateral is FLOW let openRes = _executeTransaction( "./transactions/mock-flow-credit-market-consumer/create_wrapped_position.cdc", [1000.0, /storage/flowTokenVault, true], @@ -70,14 +71,18 @@ fun test_liquidation_phase1_quote_and_execute() { // execute liquidation let liquidator = Test.createAccount() setupMoetVault(liquidator, beFailed: false) - _executeTransaction("../transactions/moet/mint_moet.cdc", [liquidator.address, 1000.0], Test.getAccount(0x0000000000000007)) + let mintRes = _executeTransaction("../transactions/moet/mint_moet.cdc", [liquidator.address, 1000.0], Test.getAccount(0x0000000000000007)) + Test.expect(mintRes, Test.beSucceeded()) - let liqBalance = getBalance(address: liquidator.address, vaultPublicPath: /public/moetBalance) ?? 0.0 + let liqBalance = getBalance(address: liquidator.address, vaultPublicPath: MOET.VaultPublicPath) ?? 0.0 log("Liquidator MOET balance after mint: \(liqBalance)") + // Repay MOET to seize FLOW + let repayAmount = 2.0 + let seizeAmount = 1.0 let liqRes = _executeTransaction( "../transactions/flow-credit-market/pool-management/manual_liquidation.cdc", - [pid, Type<@MOET.Vault>().identifier, flowTokenIdentifier, 1.0, 10.0], + [pid, Type<@MOET.Vault>().identifier, flowTokenIdentifier, seizeAmount, repayAmount], liquidator ) Test.expect(liqRes, Test.beSucceeded()) diff --git a/cadence/transactions/flow-credit-market/pool-management/manual_liquidation.cdc b/cadence/transactions/flow-credit-market/pool-management/manual_liquidation.cdc index c474755..a98afcf 100644 --- a/cadence/transactions/flow-credit-market/pool-management/manual_liquidation.cdc +++ b/cadence/transactions/flow-credit-market/pool-management/manual_liquidation.cdc @@ -42,7 +42,7 @@ transaction(pid: UInt64, debtVaultIdentifier: String, seizeVaultIdentifier: Stri // Check if the service account has a vault for this token type at the correct storage path let debtVaultRef = signer.storage.borrow(from: debtVaultData.storagePath) ?? panic("no debt vault in storage at path \(debtVaultData.storagePath)") - assert(debtVaultRef.balance >= repayAmount, message: "Insufficient MOET balance") + assert(debtVaultRef.balance >= repayAmount, message: "Insufficient debt token \(debtVaultRef.getType().identifier) balance \(debtVaultRef.balance)<\(repayAmount)") self.repay <- debtVaultRef.withdraw(amount: repayAmount) let seizeVaultRef = signer.capabilities.borrow<&{FungibleToken.Receiver}>(seizeVaultData.receiverPath) From f5c77c83279fbc3885b24af97d7c03fa3e9d4794 Mon Sep 17 00:00:00 2001 From: Jordan Schalm Date: Fri, 9 Jan 2026 08:59:32 -0800 Subject: [PATCH 20/34] add tests --- cadence/tests/liquidation_phase1_test.cdc | 135 +++++++++++++++++++--- 1 file changed, 122 insertions(+), 13 deletions(-) diff --git a/cadence/tests/liquidation_phase1_test.cdc b/cadence/tests/liquidation_phase1_test.cdc index d4182e4..c040ebc 100644 --- a/cadence/tests/liquidation_phase1_test.cdc +++ b/cadence/tests/liquidation_phase1_test.cdc @@ -7,6 +7,7 @@ import "FlowToken" import "FlowCreditMarketMath" access(all) let flowTokenIdentifier = "A.0000000000000003.FlowToken.Vault" +access(all) let moetIdentifier = "A.0000000000000007.MOET.Vault" access(all) var snapshot: UInt64 = 0 access(all) @@ -38,8 +39,54 @@ fun setup() { snapshot = getCurrentBlockHeight() } +/// Should be unable to liquidate healthy position. access(all) -fun test_liquidation_phase1_quote_and_execute() { +fun testManualLiquidation_healthyPosition() { + safeReset() + let pid: UInt64 = 0 + + // user setup + let user = Test.createAccount() + setupMoetVault(user, beFailed: false) + transferFlowTokens(to: user, amount: 1000.0) + + // open wrapped position and deposit via existing helper txs + // debt is MOET, collateral is FLOW + let openRes = _executeTransaction( + "./transactions/mock-flow-credit-market-consumer/create_wrapped_position.cdc", + [1000.0, /storage/flowTokenVault, true], + user + ) + Test.expect(openRes, Test.beSucceeded()) + + // Log initial health + let hBefore = getPositionHealth(pid: pid, beFailed: false) + let hBeforeUF = FlowCreditMarketMath.toUFix64Round(hBefore) + log("[LIQ] Health before price drop: raw=\(hBefore), approx=\(hBeforeUF)") + + // execute liquidation + let liquidator = Test.createAccount() + setupMoetVault(liquidator, beFailed: false) + let mintRes = _executeTransaction("../transactions/moet/mint_moet.cdc", [liquidator.address, 1000.0], Test.getAccount(0x0000000000000007)) + Test.expect(mintRes, Test.beSucceeded()) + + let liqBalance = getBalance(address: liquidator.address, vaultPublicPath: MOET.VaultPublicPath) ?? 0.0 + log("Liquidator MOET balance after mint: \(liqBalance)") + + // Repay MOET to seize FLOW + let repayAmount = 2.0 + let seizeAmount = 1.0 + let liqRes = _executeTransaction( + "../transactions/flow-credit-market/pool-management/manual_liquidation.cdc", + [pid, Type<@MOET.Vault>().identifier, flowTokenIdentifier, seizeAmount, repayAmount], + liquidator + ) + Test.expect(liqRes, Test.beFailed()) +} + +/// Should be unable to liquidate a position to above target health. +access(all) +fun testManualLiquidation_liquidationExceedsTargetHealth() { safeReset() let pid: UInt64 = 0 @@ -78,31 +125,93 @@ fun test_liquidation_phase1_quote_and_execute() { log("Liquidator MOET balance after mint: \(liqBalance)") // Repay MOET to seize FLOW - let repayAmount = 2.0 - let seizeAmount = 1.0 + let repayAmount = 200.0 + let seizeAmount = 200.0 let liqRes = _executeTransaction( "../transactions/flow-credit-market/pool-management/manual_liquidation.cdc", [pid, Type<@MOET.Vault>().identifier, flowTokenIdentifier, seizeAmount, repayAmount], liquidator ) - Test.expect(liqRes, Test.beSucceeded()) + // Should fail because we are repaying/seizing too much + Test.expect(liqRes, Test.beFailed()) // health after liquidation let hAfterLiq = getPositionHealth(pid: pid, beFailed: false) let hAfterLiqUF = FlowCreditMarketMath.toUFix64Round(hAfterLiq) log("[LIQ] Health after liquidation: raw=\(hAfterLiq), approx=\(hAfterLiqUF)") - // TODO: re-add these - // Assert final health ≈ target - // let targetHF: UFix128 = 1.05 - // let tolerance: UFix128 = 0.00001 - // Test.assert(hAfterLiq >= targetHF - tolerance && hAfterLiq <= targetHF + tolerance, message: "Post-liquidation health \(hAfterLiqUF) not at target 1.05") + Test.assert(hAfterLiq == hAfterPrice, message: "sanity check: health should not change after failed liquidation") +} - // Assert quoted newHF matches actual - // Test.assert(quote.newHF >= targetHF - tolerance && quote.newHF <= targetHF + tolerance, message: "Quoted newHF not at target") +/// Should be unable to liquidate a position by repaying more debt than is in the position. +access(all) +fun testManualLiquidation_repayExceedsDebt() { + safeReset() + let pid: UInt64 = 0 + + // user setup + let user = Test.createAccount() + setupMoetVault(user, beFailed: false) + transferFlowTokens(to: user, amount: 1000.0) + + // open wrapped position and deposit via existing helper txs + // debt is MOET, collateral is FLOW + let openRes = _executeTransaction( + "./transactions/mock-flow-credit-market-consumer/create_wrapped_position.cdc", + [1000.0, /storage/flowTokenVault, true], + user + ) + Test.expect(openRes, Test.beSucceeded()) + + // health before price drop + let hBefore = getPositionHealth(pid: pid, beFailed: false) + let hBeforeUF = FlowCreditMarketMath.toUFix64Round(hBefore) + log("[LIQ] Health before price drop: raw=\(hBefore), approx=\(hBeforeUF)") + + // cause undercollateralization + let newPrice = 0.7 // $/FLOW + setMockOraclePrice(signer: Test.getAccount(0x0000000000000007), forTokenIdentifier: flowTokenIdentifier, price: newPrice) + let hAfterPrice = getPositionHealth(pid: pid, beFailed: false) + let hAfterPriceUF = FlowCreditMarketMath.toUFix64Round(hAfterPrice) + log("[LIQ] Health after price drop: raw=\(hAfterPrice), approx=\(hAfterPriceUF)") + + // TODO: make helper for this + let positionDetails = getPositionDetails(pid: pid, beFailed: false) + var debtBalance = 0.0 + for bal in positionDetails.balances { + if bal.vaultType == CompositeType(moetIdentifier) { + debtBalance = bal.balance + break + } + } + + // execute liquidation + let liquidator = Test.createAccount() + setupMoetVault(liquidator, beFailed: false) + let mintRes = _executeTransaction("../transactions/moet/mint_moet.cdc", [liquidator.address, 1000.0], Test.getAccount(0x0000000000000007)) + Test.expect(mintRes, Test.beSucceeded()) + + let liqBalance = getBalance(address: liquidator.address, vaultPublicPath: MOET.VaultPublicPath) ?? 0.0 + log("Liquidator MOET balance after mint: \(liqBalance)") + + // Repay MOET to seize FLOW. Choose repay amount above debt balance + let repayAmount = debtBalance + 0.001 + let seizeAmount = (repayAmount / newPrice) * 0.99 + let liqRes = _executeTransaction( + "../transactions/flow-credit-market/pool-management/manual_liquidation.cdc", + [pid, Type<@MOET.Vault>().identifier, flowTokenIdentifier, seizeAmount, repayAmount], + liquidator + ) + // Should fail because we are repaying too much + Test.expect(liqRes, Test.beFailed()) + Test.assertError(liqRes, errorMessage: "Cannot repay more debt than is in position") + + // health after liquidation + let hAfterLiq = getPositionHealth(pid: pid, beFailed: false) + let hAfterLiqUF = FlowCreditMarketMath.toUFix64Round(hAfterLiq) + log("[LIQ] Health after liquidation: raw=\(hAfterLiq), approx=\(hAfterLiqUF)") - // let detailsAfter = getPositionDetails(pid: pid, beFailed: false) - // Test.assert(detailsAfter.health >= targetHF - tolerance, message: "Health not restored") + Test.assert(hAfterLiq == hAfterPrice, message: "sanity check: health should not change after failed liquidation") } // DEX liquidation tests moved to liquidation_phase2_dex_test.cdc From d01c659f00bd87b4aa97281f4d61c40b4143d4f6 Mon Sep 17 00:00:00 2001 From: Jordan Schalm Date: Fri, 9 Jan 2026 09:57:59 -0800 Subject: [PATCH 21/34] add collateral overage test --- cadence/tests/liquidation_phase1_test.cdc | 348 +++++----------------- cadence/tests/test_helpers.cdc | 11 + 2 files changed, 84 insertions(+), 275 deletions(-) diff --git a/cadence/tests/liquidation_phase1_test.cdc b/cadence/tests/liquidation_phase1_test.cdc index c040ebc..41f4097 100644 --- a/cadence/tests/liquidation_phase1_test.cdc +++ b/cadence/tests/liquidation_phase1_test.cdc @@ -63,6 +63,7 @@ fun testManualLiquidation_healthyPosition() { let hBefore = getPositionHealth(pid: pid, beFailed: false) let hBeforeUF = FlowCreditMarketMath.toUFix64Round(hBefore) log("[LIQ] Health before price drop: raw=\(hBefore), approx=\(hBeforeUF)") + Test.assert(hBefore >= 1.0, message: "initial position state is unhealthy") // execute liquidation let liquidator = Test.createAccount() @@ -110,7 +111,8 @@ fun testManualLiquidation_liquidationExceedsTargetHealth() { log("[LIQ] Health before price drop: raw=\(hBefore), approx=\(hBeforeUF)") // cause undercollateralization - setMockOraclePrice(signer: Test.getAccount(0x0000000000000007), forTokenIdentifier: flowTokenIdentifier, price: 0.7) + let newPrice = 0.7 // $/FLOW + setMockOraclePrice(signer: Test.getAccount(0x0000000000000007), forTokenIdentifier: flowTokenIdentifier, price: newPrice) let hAfterPrice = getPositionHealth(pid: pid, beFailed: false) let hAfterPriceUF = FlowCreditMarketMath.toUFix64Round(hAfterPrice) log("[LIQ] Health after price drop: raw=\(hAfterPrice), approx=\(hAfterPriceUF)") @@ -124,9 +126,10 @@ fun testManualLiquidation_liquidationExceedsTargetHealth() { let liqBalance = getBalance(address: liquidator.address, vaultPublicPath: MOET.VaultPublicPath) ?? 0.0 log("Liquidator MOET balance after mint: \(liqBalance)") - // Repay MOET to seize FLOW - let repayAmount = 200.0 - let seizeAmount = 200.0 + // Repay MOET to seize FLOW. + // TODO(jord): add helper to compute health boundaries given best acceptable price, then test boundaries + let repayAmount = 500.0 + let seizeAmount = 500.0 let liqRes = _executeTransaction( "../transactions/flow-credit-market/pool-management/manual_liquidation.cdc", [pid, Type<@MOET.Vault>().identifier, flowTokenIdentifier, seizeAmount, repayAmount], @@ -143,7 +146,7 @@ fun testManualLiquidation_liquidationExceedsTargetHealth() { Test.assert(hAfterLiq == hAfterPrice, message: "sanity check: health should not change after failed liquidation") } -/// Should be unable to liquidate a position by repaying more debt than is in the position. +/// Should be unable to liquidate a position by repaying more debt than the position holds. access(all) fun testManualLiquidation_repayExceedsDebt() { safeReset() @@ -175,15 +178,9 @@ fun testManualLiquidation_repayExceedsDebt() { let hAfterPriceUF = FlowCreditMarketMath.toUFix64Round(hAfterPrice) log("[LIQ] Health after price drop: raw=\(hAfterPrice), approx=\(hAfterPriceUF)") - // TODO: make helper for this - let positionDetails = getPositionDetails(pid: pid, beFailed: false) - var debtBalance = 0.0 - for bal in positionDetails.balances { - if bal.vaultType == CompositeType(moetIdentifier) { - debtBalance = bal.balance - break - } - } + let debtPositionBalance = getPositionBalance(pid: pid, vaultID: moetIdentifier) + Test.assert(debtPositionBalance.direction == FlowCreditMarket.BalanceDirection.Debit) + var debtBalance = debtPositionBalance.balance // execute liquidation let liquidator = Test.createAccount() @@ -214,264 +211,65 @@ fun testManualLiquidation_repayExceedsDebt() { Test.assert(hAfterLiq == hAfterPrice, message: "sanity check: health should not change after failed liquidation") } -// DEX liquidation tests moved to liquidation_phase2_dex_test.cdc - -// access(all) -// fun test_liquidation_insolvency() { -// safeReset() -// let pid: UInt64 = 0 - -// let user = Test.createAccount() -// setupMoetVault(user, beFailed: false) -// transferFlowTokens(to: user, amount: 1000.0) - -// let openRes = _executeTransaction( -// "./transactions/mock-flow-credit-market-consumer/create_wrapped_position.cdc", -// [1000.0, /storage/flowTokenVault, true], -// user -// ) -// Test.expect(openRes, Test.beSucceeded()) - -// // Severe undercollateralization (insolvent, but liquidation improves HF) -// setMockOraclePrice(signer: Test.getAccount(0x0000000000000007), forTokenIdentifier: flowTokenIdentifier, price: 0.6) -// let hAfter = getPositionHealth(pid: pid, beFailed: false) -// Test.assert(FlowCreditMarketMath.toUFix64Round(hAfter) < 1.0) - -// // Quote should suggest partial repay/seize that improves HF to max possible < target -// let quoteRes = _executeScript( -// "../scripts/flow-credit-market/quote_liquidation.cdc", -// [pid, Type<@MOET.Vault>().identifier, flowTokenIdentifier] -// ) -// Test.expect(quoteRes, Test.beSucceeded()) -// let quote = quoteRes.returnValue as! FlowCreditMarket.LiquidationQuote -// if quote.requiredRepay == 0.0 { -// // In deep insolvency with liquidation bonus, keeper repay-for-seize can worsen HF; expect no keeper quote -// Test.assert(quote.seizeAmount == 0.0, message: "Expected zero seize when repay is zero") -// return -// } -// Test.assert(quote.seizeAmount > 0.0, message: "Expected positive seizeAmount") -// Test.assert(quote.newHF > hAfter && quote.newHF < 1.0) - -// // Execute and assert improvement, HF < target -// let keeper = Test.createAccount() -// setupMoetVault(keeper, beFailed: false) -// _executeTransaction("../transactions/moet/mint_moet.cdc", [keeper.address, quote.requiredRepay + 0.00000001], Test.getAccount(0x0000000000000007)) - -// let liqRes = _executeTransaction( -// "../transactions/flow-credit-market/pool-management/liquidate_repay_for_seize.cdc", -// [pid, Type<@MOET.Vault>().identifier, flowTokenIdentifier, quote.requiredRepay + 0.00000001, 0.0], -// keeper -// ) -// Test.expect(liqRes, Test.beSucceeded()) - -// // Health should be max (zero debt left after partial repay) -// let hFinal = getPositionHealth(pid: pid, beFailed: false) -// let hFinalUF = FlowCreditMarketMath.toUFix64Round(hFinal) -// log("hFinal: \(FlowCreditMarketMath.toUFix64Round(hFinal)), hAfter: \(FlowCreditMarketMath.toUFix64Round(hAfter))") -// Test.assert(hFinal > hAfter, message: "Health not improved") -// Test.assert(hFinalUF <= 1.05, message: "Insolvent HF exceeded target") -// } - -// access(all) -// fun test_multi_liquidation() { -// safeReset() -// let pid: UInt64 = 0 - -// let user = Test.createAccount() -// setupMoetVault(user, beFailed: false) -// transferFlowTokens(to: user, amount: 1000.0) - -// _executeTransaction( -// "./transactions/mock-flow-credit-market-consumer/create_wrapped_position.cdc", -// [1000.0, /storage/flowTokenVault, true], -// user -// ) - -// // Initial undercollateralization -// setMockOraclePrice(signer: Test.getAccount(0x0000000000000007), forTokenIdentifier: flowTokenIdentifier, price: 0.7) -// let hInitial = getPositionHealth(pid: pid, beFailed: false) -// Test.assert(FlowCreditMarketMath.toUFix64Round(hInitial) < 1.0) - -// // First liquidation -// let quote1Res = _executeScript( -// "../scripts/flow-credit-market/quote_liquidation.cdc", -// [pid, Type<@MOET.Vault>().identifier, flowTokenIdentifier] -// ) -// let quote1 = quote1Res.returnValue as! FlowCreditMarket.LiquidationQuote - -// let keeper1 = Test.createAccount() -// setupMoetVault(keeper1, beFailed: false) -// _executeTransaction("../transactions/moet/mint_moet.cdc", [keeper1.address, quote1.requiredRepay], Test.getAccount(0x0000000000000007)) - -// _executeTransaction( -// "../transactions/flow-credit-market/pool-management/liquidate_repay_for_seize.cdc", -// [pid, Type<@MOET.Vault>().identifier, flowTokenIdentifier, quote1.requiredRepay, 0.0], -// keeper1 -// ) - -// let hAfter1 = getPositionHealth(pid: pid, beFailed: false) -// let targetHF: UFix128 = 1.05 -// // Slightly relax tolerance for second liquidation to account for rounding across sequential updates -// let tolerance: UFix128 = 0.00002 -// Test.assert(hAfter1 >= targetHF - tolerance, message: "First liquidation did not reach target") - -// // Drop price further for second liquidation -// setMockOraclePrice(signer: Test.getAccount(0x0000000000000007), forTokenIdentifier: flowTokenIdentifier, price: 0.6) - -// let hAfterDrop = getPositionHealth(pid: pid, beFailed: false) -// Test.assert(FlowCreditMarketMath.toUFix64Round(hAfterDrop) < 1.0) - -// // Second liquidation -// let quote2Res = _executeScript( -// "../scripts/flow-credit-market/quote_liquidation.cdc", -// [pid, Type<@MOET.Vault>().identifier, flowTokenIdentifier] -// ) -// let quote2 = quote2Res.returnValue as! FlowCreditMarket.LiquidationQuote - -// let keeper2 = Test.createAccount() -// setupMoetVault(keeper2, beFailed: false) -// _executeTransaction("../transactions/moet/mint_moet.cdc", [keeper2.address, quote2.requiredRepay + 0.00000001], Test.getAccount(0x0000000000000007)) - -// _executeTransaction( -// "../transactions/flow-credit-market/pool-management/liquidate_repay_for_seize.cdc", -// [pid, Type<@MOET.Vault>().identifier, flowTokenIdentifier, quote2.requiredRepay + 0.00000001, 0.0], -// keeper2 -// ) - -// let hFinal = getPositionHealth(pid: pid, beFailed: false) -// log("[LIQ][TEST] Second liquidation hFinal UF=\(FlowCreditMarketMath.toUFix64Round(hFinal)) raw=\(hFinal)") -// Test.assert(hFinal >= targetHF - tolerance, message: "Second liquidation did not reach target") -// } - -// access(all) -// fun test_liquidation_overpay_attempt() { -// safeReset() -// let pid: UInt64 = 0 - -// let user = Test.createAccount() -// setupMoetVault(user, beFailed: false) -// transferFlowTokens(to: user, amount: 1000.0) - -// let openRes = _executeTransaction( -// "./transactions/mock-flow-credit-market-consumer/create_wrapped_position.cdc", -// [1000.0, /storage/flowTokenVault, true], -// user -// ) -// Test.expect(openRes, Test.beSucceeded()) - -// setMockOraclePrice(signer: Test.getAccount(0x0000000000000007), forTokenIdentifier: flowTokenIdentifier, price: 0.7) - -// let quoteRes = _executeScript( -// "../scripts/flow-credit-market/quote_liquidation.cdc", -// [0 as UInt64, Type<@MOET.Vault>().identifier, flowTokenIdentifier] -// ) -// let quote = quoteRes.returnValue as! FlowCreditMarket.LiquidationQuote -// if quote.requiredRepay == 0.0 { -// // Near-threshold rounding case may produce zero-step; nothing to liquidate -// return -// } - -// let liquidator = Test.createAccount() -// setupMoetVault(liquidator, beFailed: false) -// let overpayAmount = quote.requiredRepay + 10.0 -// _executeTransaction("../transactions/moet/mint_moet.cdc", [liquidator.address, overpayAmount], Test.getAccount(0x0000000000000007)) - -// let balanceBefore = getBalance(address: liquidator.address, vaultPublicPath: MOET.ReceiverPublicPath) ?? 0.0 -// let collBalanceBefore = getBalance(address: liquidator.address, vaultPublicPath: /public/flowTokenReceiver) ?? 0.0 - -// let liqRes = _executeTransaction( -// "../transactions/flow-credit-market/pool-management/liquidate_repay_for_seize.cdc", -// [pid, Type<@MOET.Vault>().identifier, flowTokenIdentifier, overpayAmount, 0.0], -// liquidator -// ) -// Test.expect(liqRes, Test.beSucceeded()) - -// let balanceAfter = getBalance(address: liquidator.address, vaultPublicPath: MOET.ReceiverPublicPath) ?? 0.0 -// let collBalanceAfter = getBalance(address: liquidator.address, vaultPublicPath: /public/flowTokenReceiver) ?? 0.0 - -// Test.assert(balanceAfter == balanceBefore - quote.requiredRepay, message: "Actual repay not equal to requiredRepay") -// Test.assert(collBalanceAfter == collBalanceBefore + quote.seizeAmount, message: "Seize amount changed") -// } - -// access(all) -// fun test_liquidation_slippage_failure() { -// safeReset() -// let pid: UInt64 = 0 - -// // Setup similar to first test -// let user = Test.createAccount() -// setupMoetVault(user, beFailed: false) -// transferFlowTokens(to: user, amount: 1000.0) - -// _executeTransaction( -// "./transactions/mock-flow-credit-market-consumer/create_wrapped_position.cdc", -// [1000.0, /storage/flowTokenVault, true], -// user -// ) - -// setMockOraclePrice(signer: Test.getAccount(0x0000000000000007), forTokenIdentifier: flowTokenIdentifier, price: 0.7) - -// let quoteRes = _executeScript( -// "../scripts/flow-credit-market/quote_liquidation.cdc", -// [pid, Type<@MOET.Vault>().identifier, flowTokenIdentifier] -// ) -// let quote = quoteRes.returnValue as! FlowCreditMarket.LiquidationQuote - -// let liquidator = Test.createAccount() -// setupMoetVault(liquidator, beFailed: false) -// _executeTransaction("../transactions/moet/mint_moet.cdc", [liquidator.address, quote.requiredRepay], Test.getAccount(0x0000000000000007)) - -// // max < required -> revert -// let lowMaxRes = _executeTransaction( -// "../transactions/flow-credit-market/pool-management/liquidate_repay_for_seize.cdc", -// [pid, Type<@MOET.Vault>().identifier, flowTokenIdentifier, quote.requiredRepay - 0.00000001, 0.0], -// liquidator -// ) -// Test.expect(lowMaxRes, Test.beFailed()) - -// // min > seize -> revert -// let highMinRes = _executeTransaction( -// "../transactions/flow-credit-market/pool-management/liquidate_repay_for_seize.cdc", -// [pid, Type<@MOET.Vault>().identifier, flowTokenIdentifier, quote.requiredRepay, quote.seizeAmount + 0.1], -// liquidator -// ) -// Test.expect(highMinRes, Test.beFailed()) -// } - - -// access(all) -// fun test_liquidation_healthy_zero_quote() { -// safeReset() -// let pid: UInt64 = 0 - -// let user = Test.createAccount() -// setupMoetVault(user, beFailed: false) -// transferFlowTokens(to: user, amount: 1000.0) - -// let openRes = _executeTransaction( -// "./transactions/mock-flow-credit-market-consumer/create_wrapped_position.cdc", -// [1000.0, /storage/flowTokenVault, true], -// user -// ) -// Test.expect(openRes, Test.beSucceeded()) - -// // Set price to make HF > 1.0 (healthy) -// setMockOraclePrice(signer: Test.getAccount(0x0000000000000007), forTokenIdentifier: flowTokenIdentifier, price: 1.2) - -// let h = getPositionHealth(pid: pid, beFailed: false) -// let hUF = FlowCreditMarketMath.toUFix64Round(h) -// Test.assert(hUF > 1.0, message: "Position not healthy") - -// let quoteRes = _executeScript( -// "../scripts/flow-credit-market/quote_liquidation.cdc", -// [pid, Type<@MOET.Vault>().identifier, flowTokenIdentifier] -// ) -// Test.expect(quoteRes, Test.beSucceeded()) -// let quote = quoteRes.returnValue as! FlowCreditMarket.LiquidationQuote - -// Test.assert(quote.requiredRepay == 0.0, message: "Required repay not zero for healthy position") -// Test.assert(quote.seizeAmount == 0.0, message: "Seize amount not zero for healthy position") -// Test.assert(quote.newHF == h, message: "New HF not matching current health") -// } - -// Time-based warmup enforcement test removed +/// Should be unable to liquidate a position by seizing more collateral than the position holds. +access(all) +fun testManualLiquidation_seizeExceedsCollateral() { + safeReset() + let pid: UInt64 = 0 + + // user setup + let user = Test.createAccount() + setupMoetVault(user, beFailed: false) + transferFlowTokens(to: user, amount: 1000.0) + + // open wrapped position and deposit via existing helper txs + // debt is MOET, collateral is FLOW + let openRes = _executeTransaction( + "./transactions/mock-flow-credit-market-consumer/create_wrapped_position.cdc", + [1000.0, /storage/flowTokenVault, true], + user + ) + Test.expect(openRes, Test.beSucceeded()) + + // health before price drop + let hBefore = getPositionHealth(pid: pid, beFailed: false) + let hBeforeUF = FlowCreditMarketMath.toUFix64Round(hBefore) + log("[LIQ] Health before price drop: raw=\(hBefore), approx=\(hBeforeUF)") + + // cause undercollateralization AND insolvency + let newPrice = 0.5 // $/FLOW + setMockOraclePrice(signer: Test.getAccount(0x0000000000000007), forTokenIdentifier: flowTokenIdentifier, price: newPrice) + let hAfterPrice = getPositionHealth(pid: pid, beFailed: false) + let hAfterPriceUF = FlowCreditMarketMath.toUFix64Round(hAfterPrice) + log("[LIQ] Health after price drop: raw=\(hAfterPrice), approx=\(hAfterPriceUF)") + + let collateralBalance = getPositionBalance(pid: pid, vaultID: flowTokenIdentifier).balance + + // execute liquidation + let liquidator = Test.createAccount() + setupMoetVault(liquidator, beFailed: false) + let mintRes = _executeTransaction("../transactions/moet/mint_moet.cdc", [liquidator.address, 1000.0], Test.getAccount(0x0000000000000007)) + Test.expect(mintRes, Test.beSucceeded()) + + let liqBalance = getBalance(address: liquidator.address, vaultPublicPath: MOET.VaultPublicPath) ?? 0.0 + log("Liquidator MOET balance after mint: \(liqBalance)") + + // Repay MOET to seize FLOW. Choose seize amount above collateral balance + let seizeAmount = collateralBalance + 0.001 + let repayAmount = seizeAmount * newPrice * 1.01 + let liqRes = _executeTransaction( + "../transactions/flow-credit-market/pool-management/manual_liquidation.cdc", + [pid, Type<@MOET.Vault>().identifier, flowTokenIdentifier, seizeAmount, repayAmount], + liquidator + ) + // Should fail because we are seizing too much collateral + Test.expect(liqRes, Test.beFailed()) + Test.assertError(liqRes, errorMessage: "Cannot seize more collateral than is in position") + + // health after liquidation + let hAfterLiq = getPositionHealth(pid: pid, beFailed: false) + let hAfterLiqUF = FlowCreditMarketMath.toUFix64Round(hAfterLiq) + log("[LIQ] Health after liquidation: raw=\(hAfterLiq), approx=\(hAfterLiqUF)") + + Test.assert(hAfterLiq == hAfterPrice, message: "sanity check: health should not change after failed liquidation") +} diff --git a/cadence/tests/test_helpers.cdc b/cadence/tests/test_helpers.cdc index b5ae662..a520949 100644 --- a/cadence/tests/test_helpers.cdc +++ b/cadence/tests/test_helpers.cdc @@ -182,6 +182,17 @@ fun getPositionDetails(pid: UInt64, beFailed: Bool): FlowCreditMarket.PositionDe return res.returnValue as! FlowCreditMarket.PositionDetails } +access(all) +fun getPositionBalance(pid: UInt64, vaultID: String): FlowCreditMarket.PositionBalance { + let positionDetails = getPositionDetails(pid: pid, beFailed: false) + for bal in positionDetails.balances { + if bal.vaultType == CompositeType(vaultID) { + return bal + } + } + panic("expected to find balance for \(vaultID) in position\(pid)") +} + access(all) fun poolExists(address: Address): Bool { let res = _executeScript("../scripts/flow-credit-market/pool_exists.cdc", [address]) From 9cc5038d8ab82d1add8889976e8ba266a5cd90df Mon Sep 17 00:00:00 2001 From: Jordan Schalm Date: Fri, 9 Jan 2026 09:59:51 -0800 Subject: [PATCH 22/34] check specific error messages --- cadence/tests/liquidation_phase1_test.cdc | 2 ++ 1 file changed, 2 insertions(+) diff --git a/cadence/tests/liquidation_phase1_test.cdc b/cadence/tests/liquidation_phase1_test.cdc index 41f4097..9d97dcb 100644 --- a/cadence/tests/liquidation_phase1_test.cdc +++ b/cadence/tests/liquidation_phase1_test.cdc @@ -83,6 +83,7 @@ fun testManualLiquidation_healthyPosition() { liquidator ) Test.expect(liqRes, Test.beFailed()) + Test.assertError(liqRes, errorMessage: "Cannot liquidate healthy position") } /// Should be unable to liquidate a position to above target health. @@ -137,6 +138,7 @@ fun testManualLiquidation_liquidationExceedsTargetHealth() { ) // Should fail because we are repaying/seizing too much Test.expect(liqRes, Test.beFailed()) + Test.assertError(liqRes, errorMessage: "Liquidation must not exceed target health") // health after liquidation let hAfterLiq = getPositionHealth(pid: pid, beFailed: false) From f440c14eadcd1842c4e70498346f7572506984e9 Mon Sep 17 00:00:00 2001 From: Jordan Schalm Date: Fri, 9 Jan 2026 10:12:29 -0800 Subject: [PATCH 23/34] add test stubs --- cadence/contracts/FlowCreditMarket.cdc | 7 -- cadence/tests/liquidation_phase1_test.cdc | 89 +++++++++++++++++++++++ 2 files changed, 89 insertions(+), 7 deletions(-) diff --git a/cadence/contracts/FlowCreditMarket.cdc b/cadence/contracts/FlowCreditMarket.cdc index 42eb199..7b5fe06 100644 --- a/cadence/contracts/FlowCreditMarket.cdc +++ b/cadence/contracts/FlowCreditMarket.cdc @@ -1375,12 +1375,6 @@ access(all) contract FlowCreditMarket { /// - C means collateral: Ce is effective collateral, Ct is true collateral, measured in $ /// - D means debt: De is effective debt, Dt is true debt, measured in $ /// - Fc, Fd are collateral and debt factors - /// - /// TODO: Test cases: - /// - proposal brings health above target (not allowed) - /// - proposal reduces health (allowed) - /// - proposal pays more debt than position has - /// - proposal seizes more collateral than position has access(all) fun manualLiquidation( pid: UInt64, debtType: Type, @@ -1390,7 +1384,6 @@ access(all) contract FlowCreditMarket { ): @{FungibleToken.Vault} { pre { // debt, collateral are both supported tokens - // repaymentSource has sufficient balance // liquidationsPaused is false // liquidation warmup? } diff --git a/cadence/tests/liquidation_phase1_test.cdc b/cadence/tests/liquidation_phase1_test.cdc index 9d97dcb..a97c667 100644 --- a/cadence/tests/liquidation_phase1_test.cdc +++ b/cadence/tests/liquidation_phase1_test.cdc @@ -275,3 +275,92 @@ fun testManualLiquidation_seizeExceedsCollateral() { Test.assert(hAfterLiq == hAfterPrice, message: "sanity check: health should not change after failed liquidation") } + +/// Should be able to liquidate a position, even if liquidation reduces health, if other conditions are met. +access(all) +fun testManualLiquidation_reduceHealth() { + safeReset() + let pid: UInt64 = 0 + + // user setup + let user = Test.createAccount() + setupMoetVault(user, beFailed: false) + transferFlowTokens(to: user, amount: 1000.0) + + // open wrapped position and deposit via existing helper txs + // debt is MOET, collateral is FLOW + let openRes = _executeTransaction( + "./transactions/mock-flow-credit-market-consumer/create_wrapped_position.cdc", + [1000.0, /storage/flowTokenVault, true], + user + ) + Test.expect(openRes, Test.beSucceeded()) + + // health before price drop + let hBefore = getPositionHealth(pid: pid, beFailed: false) + let hBeforeUF = FlowCreditMarketMath.toUFix64Round(hBefore) + log("[LIQ] Health before price drop: raw=\(hBefore), approx=\(hBeforeUF)") + + // cause undercollateralization AND insolvency + let newPrice = 0.5 // $/FLOW + setMockOraclePrice(signer: Test.getAccount(0x0000000000000007), forTokenIdentifier: flowTokenIdentifier, price: newPrice) + let hAfterPrice = getPositionHealth(pid: pid, beFailed: false) + let hAfterPriceUF = FlowCreditMarketMath.toUFix64Round(hAfterPrice) + log("[LIQ] Health after price drop: raw=\(hAfterPrice), approx=\(hAfterPriceUF)") + + let collateralBalance = getPositionBalance(pid: pid, vaultID: flowTokenIdentifier).balance + + // execute liquidation + let liquidator = Test.createAccount() + setupMoetVault(liquidator, beFailed: false) + let mintRes = _executeTransaction("../transactions/moet/mint_moet.cdc", [liquidator.address, 1000.0], Test.getAccount(0x0000000000000007)) + Test.expect(mintRes, Test.beSucceeded()) + + let liqBalance = getBalance(address: liquidator.address, vaultPublicPath: MOET.VaultPublicPath) ?? 0.0 + log("Liquidator MOET balance after mint: \(liqBalance)") + + // Repay MOET to seize FLOW. Choose seize amount above collateral balance + let seizeAmount = collateralBalance - 0.01 + let repayAmount = seizeAmount * newPrice * 1.01 + let liqRes = _executeTransaction( + "../transactions/flow-credit-market/pool-management/manual_liquidation.cdc", + [pid, Type<@MOET.Vault>().identifier, flowTokenIdentifier, seizeAmount, repayAmount], + liquidator + ) + // Should succeed, even though we are reducing health + Test.expect(liqRes, Test.beSucceeded()) + + // TODO(jord): validate post-liquidation balances + + // health after liquidation + let hAfterLiq = getPositionHealth(pid: pid, beFailed: false) + let hAfterLiqUF = FlowCreditMarketMath.toUFix64Round(hAfterLiq) + log("[LIQ] Health after liquidation: raw=\(hAfterLiq), approx=\(hAfterLiqUF)") + Test.assert(hAfterLiq < hAfterPrice, message: "test expects health to decrease after liquidation") +} + +/// Should be able to liquidate to below target health while increasing health factor. +access(all) +fun testManualLiquidation_increaseHealthBelowTarget() {} + +/// Should be able to liquidate to exactly target health +access(all) +fun testManualLiquidation_liquidateToTarget() {} + +access(all) +fun testManualLiquidation_repaymentVaultWrongType() {} + +access(all) +fun testManualLiquidation_unsupportedDebtType() {} + +access(all) +fun testManualLiquidation_unsupportedCollateralType() {} + +access(all) +fun testManualLiquidation_liquidationPaused() {} + +access(all) +fun testManualLiquidation_liquidationWarmup() {} + +access(all) +fun testManualLiquidation_dexOraclePriceDivergence() {} \ No newline at end of file From 87ddbc2e25dbac027ea6359e2aa4568abc680a50 Mon Sep 17 00:00:00 2001 From: Jordan Schalm Date: Fri, 9 Jan 2026 10:24:18 -0800 Subject: [PATCH 24/34] clarify credit/debit rate fields --- cadence/contracts/FlowCreditMarket.cdc | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/cadence/contracts/FlowCreditMarket.cdc b/cadence/contracts/FlowCreditMarket.cdc index 7b5fe06..0e7f96a 100644 --- a/cadence/contracts/FlowCreditMarket.cdc +++ b/cadence/contracts/FlowCreditMarket.cdc @@ -585,16 +585,16 @@ access(all) contract FlowCreditMarket { /// to maintain precision when converting between scaled and true balances and when compounding. access(all) var debitInterestIndex: UFix128 - /// The interest rate for credit of the associated token. + /// The per-second interest rate for credit of the associated token. /// + /// For example, if the per-second rate is 1%, this value is 0.01. /// Stored as UFix128 to match index precision and avoid cumulative rounding during compounding. - // TODO: format: APR? In call to compoundInterestIndex, assumes this is per-second rate? access(all) var currentCreditRate: UFix128 - /// The interest rate for debit of the associated token. + /// The per-second interest rate for debit of the associated token. /// + /// For example, if the per-second rate is 1%, this value is 0.01. /// Stored as UFix128 for consistency with indices/rates math. - // TODO: format: APR? access(all) var currentDebitRate: UFix128 /// The interest curve implementation used to calculate interest rate From c9c35458722c0752c5757289dabf487d513c7a6c Mon Sep 17 00:00:00 2001 From: Jordan Schalm Date: Fri, 9 Jan 2026 10:38:35 -0800 Subject: [PATCH 25/34] re-add unused field (upgradability) --- cadence/contracts/FlowCreditMarket.cdc | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/cadence/contracts/FlowCreditMarket.cdc b/cadence/contracts/FlowCreditMarket.cdc index 0e7f96a..3c3ffd0 100644 --- a/cadence/contracts/FlowCreditMarket.cdc +++ b/cadence/contracts/FlowCreditMarket.cdc @@ -868,6 +868,7 @@ access(all) contract FlowCreditMarket { self.maxHealth = max } + /// Returns the true balance of the given token in this position, accounting for interest. access(all) fun trueBalance(ofToken: Type): UFix128 { let balance = self.balances[ofToken]! let tokenSnapshot = self.snapshots[ofToken]! @@ -1069,6 +1070,9 @@ access(all) contract FlowCreditMarket { /// Time this pool most recently had liquidations paused access(self) var lastUnpausedAt: UInt64? + /// TODO: unused! To remove, must re-deploy existing contracts + access(self) var protocolLiquidationFeeBps: UInt16 + // TODO(jord): figure out how to reference dex https://github.com/onflow/FlowCreditMarket/issues/94 // - either need to redeploy contract to create new dex field // - or need to revert to allowlist pattern and pass in swapper instances (I worry about security of this option) @@ -1118,6 +1122,7 @@ access(all) contract FlowCreditMarket { self.liquidationsPaused = false self.liquidationWarmupSec = 300 self.lastUnpausedAt = nil + self.protocolLiquidationFeeBps = 0 self.allowedSwapperTypes = {} self.dex = nil self.dexOracleDeviationBps = UInt16(300) // 3% default @@ -1423,7 +1428,7 @@ access(all) contract FlowCreditMarket { let postHealth = FlowCreditMarket.healthComputation(effectiveCollateral: Ce_post, effectiveDebt: De_post) assert(postHealth <= self.liquidationTargetHF, message: "Liquidation must not exceed target health: \(postHealth)>\(self.liquidationTargetHF)") - // TODO(jord): uncomment following when implementing SwapperProvider + // TODO(jord): uncomment following when implementing dex logic https://github.com/onflow/FlowCreditMarket/issues/94 /* // Compare the liquidation offer to liquidation via DEX. If the DEX would provide a better price, reject the offer. let swapper = self.dex!.getSwapper(inType: seizeType, outType: debtType)! // TODO: will revert if pair unsupported From d5d55188802415fb74835913811b2cd26d5bbfd6 Mon Sep 17 00:00:00 2001 From: Jordan Schalm Date: Fri, 9 Jan 2026 10:41:09 -0800 Subject: [PATCH 26/34] remove existing dex test --- cadence/tests/liquidation_phase2_dex_test.cdc | 86 ------------------- 1 file changed, 86 deletions(-) delete mode 100644 cadence/tests/liquidation_phase2_dex_test.cdc diff --git a/cadence/tests/liquidation_phase2_dex_test.cdc b/cadence/tests/liquidation_phase2_dex_test.cdc deleted file mode 100644 index 1c50387..0000000 --- a/cadence/tests/liquidation_phase2_dex_test.cdc +++ /dev/null @@ -1,86 +0,0 @@ -// import Test -// import BlockchainHelpers -// import "test_helpers.cdc" - -// import "FlowCreditMarket" -// import "MOET" -// import "FlowToken" -// import "FlowCreditMarketMath" -// import "MockDexSwapper" - -// access(all) -// fun setup() { -// deployContracts() - -// let protocolAccount = Test.getAccount(0x0000000000000007) - -// setMockOraclePrice(signer: protocolAccount, forTokenIdentifier: Type<@FlowToken.Vault>().identifier, price: 1.0) -// createAndStorePool(signer: protocolAccount, defaultTokenIdentifier: Type<@MOET.Vault>().identifier, beFailed: false) -// grantPoolCapToConsumer() -// addSupportedTokenZeroRateCurve( -// signer: protocolAccount, -// tokenTypeIdentifier: Type<@FlowToken.Vault>().identifier, -// collateralFactor: 0.8, -// borrowFactor: 1.0, -// depositRate: 1_000_000.0, -// depositCapacityCap: 1_000_000.0 -// ) -// } - -// access(all) -// fun test_liquidation_via_dex() { -// let pid: UInt64 = 0 - -// // user setup -// let user = Test.createAccount() -// setupMoetVault(user, beFailed: false) -// transferFlowTokens(to: user, amount: 1000.0) - -// _executeTransaction( -// "./transactions/mock-flow-credit-market-consumer/create_wrapped_position.cdc", -// [1000.0, /storage/flowTokenVault, true], -// user -// ) - -// // make unhealthy -// setMockOraclePrice(signer: Test.getAccount(0x0000000000000007), forTokenIdentifier: Type<@FlowToken.Vault>().identifier, price: 0.7) -// let h0 = getPositionHealth(pid: pid, beFailed: false) -// Test.assert(FlowCreditMarketMath.toUFix64Round(h0) < 1.0) - -// // perform liquidation via mock dex using signer as protocol -// let protocol = Test.getAccount(0x0000000000000007) -// // allowlist MockDexSwapper -// let swapperTypeId = Type().identifier -// let allowTx = Test.Transaction( -// code: Test.readFile("../transactions/flow-credit-market/pool-governance/set_dex_liquidation_config.cdc"), -// authorizers: [protocol.address], -// signers: [protocol], -// arguments: [nil, [swapperTypeId], nil, nil, nil] -// ) -// let allowRes = Test.executeTransaction(allowTx) -// Test.expect(allowRes, Test.beSucceeded()) -// // ensure protocol MOET liquidity for DEX swapper source -// setupMoetVault(protocol, beFailed: false) -// mintMoet(signer: protocol, to: protocol.address, amount: 1_000_000.0, beFailed: false) -// let txRes = _executeTransaction( -// "../transactions/flow-credit-market/pool-management/liquidate_via_mock_dex.cdc", -// [pid, Type<@MOET.Vault>(), Type<@FlowToken.Vault>(), 1000.0, 0.0, 1.42857143], -// protocol -// ) -// Test.expect(txRes, Test.beSucceeded()) - -// // HF should improve to at/near target -// let h1 = getPositionHealth(pid: pid, beFailed: false) -// let target: UFix128 = 1.05 -// let tol: UFix128 = 0.00001 -// Test.assert(h1 >= target - tol) -// } - - -// access(all) -// fun test_mockdex_quote_math_placeholder_noop() { -// // Moved to dedicated file to avoid redeploy collisions in CI -// Test.assert(true) -// } - - From 0fdb73cb21eda0e2233f18de88761316ca3c08af Mon Sep 17 00:00:00 2001 From: Jordan Schalm Date: Fri, 9 Jan 2026 10:41:41 -0800 Subject: [PATCH 27/34] remove quote liquidation tx --- .../flow-credit-market/quote_liquidation.cdc | 13 ------------- 1 file changed, 13 deletions(-) delete mode 100644 cadence/scripts/flow-credit-market/quote_liquidation.cdc diff --git a/cadence/scripts/flow-credit-market/quote_liquidation.cdc b/cadence/scripts/flow-credit-market/quote_liquidation.cdc deleted file mode 100644 index 5732a78..0000000 --- a/cadence/scripts/flow-credit-market/quote_liquidation.cdc +++ /dev/null @@ -1,13 +0,0 @@ -import "FlowCreditMarket" - -access(all) -fun main(pid: UInt64, debtVaultIdentifier: String, seizeVaultIdentifier: String): FlowCreditMarket.LiquidationQuote { - let debtType = CompositeType(debtVaultIdentifier) ?? panic("Invalid debtVaultIdentifier \(debtVaultIdentifier)") - let seizeType = CompositeType(seizeVaultIdentifier) ?? panic("Invalid seizeVaultIdentifier \(seizeVaultIdentifier)") - - let protocolAddress = Type<@FlowCreditMarket.Pool>().address! - let pool = getAccount(protocolAddress).capabilities.borrow<&FlowCreditMarket.Pool>(FlowCreditMarket.PoolPublicPath) - ?? panic("Could not find Pool at path \(FlowCreditMarket.PoolPublicPath)") - - return pool.quoteLiquidation(pid: pid, debtType: debtType, seizeType: seizeType) -} From f194d1a9c2a6b801a3986e4a8c7771764fa9e8dd Mon Sep 17 00:00:00 2001 From: Jordan Schalm Date: Fri, 9 Jan 2026 10:46:53 -0800 Subject: [PATCH 28/34] remove provisional dex (to be added in separate issue) --- cadence/contracts/FlowCreditMarket.cdc | 9 ------ cadence/contracts/mocks/MockDexSwapper.cdc | 33 ---------------------- 2 files changed, 42 deletions(-) diff --git a/cadence/contracts/FlowCreditMarket.cdc b/cadence/contracts/FlowCreditMarket.cdc index 3c3ffd0..c9f7dee 100644 --- a/cadence/contracts/FlowCreditMarket.cdc +++ b/cadence/contracts/FlowCreditMarket.cdc @@ -884,13 +884,6 @@ access(all) contract FlowCreditMarket { } } - /// A wrapper around one or more DEXes. - access(all) struct interface SwapperProvider { - /// Returns a Swapper for the given trade pair, if the pair is supported. - /// Otherwise returns nil. - access(all) fun getSwapper(inType: Type, outType: Type): {DeFiActions.Swapper}? - } - // PURE HELPERS ------------------------------------------------------------- access(all) view fun effectiveCollateral(credit: UFix128, snap: TokenSnapshot): UFix128 { @@ -1080,7 +1073,6 @@ access(all) contract FlowCreditMarket { /// Allowlist of permitted DeFiActions Swapper types for DEX liquidations access(self) var allowedSwapperTypes: {Type: Bool} - access(self) var dex: {SwapperProvider}? /// Max allowed deviation in basis points between DEX-implied price and oracle price access(self) var dexOracleDeviationBps: UInt16 @@ -1124,7 +1116,6 @@ access(all) contract FlowCreditMarket { self.lastUnpausedAt = nil self.protocolLiquidationFeeBps = 0 self.allowedSwapperTypes = {} - self.dex = nil self.dexOracleDeviationBps = UInt16(300) // 3% default self.dexMaxSlippageBps = 100 self.dexMaxRouteHops = 3 diff --git a/cadence/contracts/mocks/MockDexSwapper.cdc b/cadence/contracts/mocks/MockDexSwapper.cdc index 36af2a1..2ba8a17 100644 --- a/cadence/contracts/mocks/MockDexSwapper.cdc +++ b/cadence/contracts/mocks/MockDexSwapper.cdc @@ -3,15 +3,11 @@ import "FungibleToken" import "DeFiActions" import "DeFiActionsUtils" -import "FlowCreditMarket" /// TEST-ONLY mock swapper that withdraws output from a user-provided Vault capability. /// Do NOT use in production. access(all) contract MockDexSwapper { - /// inType -> outType -> Swapper - access(contract) let swappers: {Type: {Type: Swapper}} - access(all) struct BasicQuote : DeFiActions.Quote { access(all) let inType: Type access(all) let outType: Type @@ -90,33 +86,4 @@ access(all) contract MockDexSwapper { access(contract) view fun copyID(): DeFiActions.UniqueIdentifier? { return self.uniqueID } access(contract) fun setID(_ id: DeFiActions.UniqueIdentifier?) { self.uniqueID = id } } - - /// Adds the given swapper to the contract, overwriting any previously added swapper with the same in/out type. - /// After addition, will be returned by SwapperProvider.getSwapper. - access(all) fun addSwapper(swapper: Swapper) { - if let swappersByInType = self.swappers[swapper.inType()] { - swappersByInType[swapper.outType()] = swapper - self.swappers[swapper.inType()] = swappersByInType - } else { - self.swappers[swapper.inType()] = {swapper.outType(): swapper} - } - } - - /// Provides access to the set of swappers stored in this mock contract. - /// Tests can instantiate a pool with an instance of SwapperProvider, - /// then control the DEX behaviour with addSwapper. - access(all) struct SwapperProvider : FlowCreditMarket.SwapperProvider { - access(all) fun getSwapper(inType: Type, outType: Type): {DeFiActions.Swapper}? { - if let swappersForInType = MockDexSwapper.swappers[inType] { - return swappersForInType[outType] - } - return nil - } - } - - init() { - self.swappers = {} - } } - - From c3bf5062e0e9ea5bb14a4d3dc7356653842fb3c3 Mon Sep 17 00:00:00 2001 From: Jordan Schalm Date: Mon, 12 Jan 2026 15:30:53 -0800 Subject: [PATCH 29/34] add tx+test for vault mismatch test --- cadence/contracts/FlowCreditMarket.cdc | 10 +-- cadence/tests/liquidation_phase1_test.cdc | 82 ++++++++++++++++++- cadence/tests/test_helpers.cdc | 12 +++ .../manual_liquidation_chosen_vault.cdc | 65 +++++++++++++++ .../pool-management/manual_liquidation.cdc | 2 - .../transactions/mocks/yieldtoken/mint.cdc | 29 +++++++ .../mocks/yieldtoken/setup_vault.cdc | 29 +++++++ 7 files changed, 217 insertions(+), 12 deletions(-) create mode 100644 cadence/tests/transactions/flow-credit-market/pool-management/manual_liquidation_chosen_vault.cdc create mode 100644 cadence/transactions/mocks/yieldtoken/mint.cdc create mode 100644 cadence/transactions/mocks/yieldtoken/setup_vault.cdc diff --git a/cadence/contracts/FlowCreditMarket.cdc b/cadence/contracts/FlowCreditMarket.cdc index c9f7dee..3a49b54 100644 --- a/cadence/contracts/FlowCreditMarket.cdc +++ b/cadence/contracts/FlowCreditMarket.cdc @@ -1379,12 +1379,10 @@ access(all) contract FlowCreditMarket { repayment: @{FungibleToken.Vault} ): @{FungibleToken.Vault} { pre { - // debt, collateral are both supported tokens - // liquidationsPaused is false - // liquidation warmup? - } - post { - // health factor should be <= target + self.isTokenSupported(tokenType: debtType): "Debt token type unsupported: \(debtType.identifier)" + self.isTokenSupported(tokenType: seizeType): "Collateral token type unsupported: \(seizeType.identifier)" + debtType == repayment.getType(): "Repayment vault does not match debt type: \(debtType.identifier)!=\(repayment.getType().identifier)" + // TODO(jord): liquidation paused / post-pause warm } let positionView = self.buildPositionView(pid: pid) diff --git a/cadence/tests/liquidation_phase1_test.cdc b/cadence/tests/liquidation_phase1_test.cdc index a97c667..b298465 100644 --- a/cadence/tests/liquidation_phase1_test.cdc +++ b/cadence/tests/liquidation_phase1_test.cdc @@ -3,11 +3,13 @@ import BlockchainHelpers import "test_helpers.cdc" import "FlowCreditMarket" import "MOET" +import "MockYieldToken" import "FlowToken" import "FlowCreditMarketMath" access(all) let flowTokenIdentifier = "A.0000000000000003.FlowToken.Vault" access(all) let moetIdentifier = "A.0000000000000007.MOET.Vault" +access(all) let mockYieldTokenIdentifier = "A.0000000000000007.MockYieldToken.Vault" access(all) var snapshot: UInt64 = 0 access(all) @@ -308,7 +310,8 @@ fun testManualLiquidation_reduceHealth() { let hAfterPriceUF = FlowCreditMarketMath.toUFix64Round(hAfterPrice) log("[LIQ] Health after price drop: raw=\(hAfterPrice), approx=\(hAfterPriceUF)") - let collateralBalance = getPositionBalance(pid: pid, vaultID: flowTokenIdentifier).balance + let collateralBalancePreLiq = getPositionBalance(pid: pid, vaultID: flowTokenIdentifier).balance + let debtBalancePreLiq = getPositionBalance(pid: pid, vaultID: moetIdentifier).balance // execute liquidation let liquidator = Test.createAccount() @@ -320,7 +323,7 @@ fun testManualLiquidation_reduceHealth() { log("Liquidator MOET balance after mint: \(liqBalance)") // Repay MOET to seize FLOW. Choose seize amount above collateral balance - let seizeAmount = collateralBalance - 0.01 + let seizeAmount = collateralBalancePreLiq - 0.01 let repayAmount = seizeAmount * newPrice * 1.01 let liqRes = _executeTransaction( "../transactions/flow-credit-market/pool-management/manual_liquidation.cdc", @@ -330,7 +333,14 @@ fun testManualLiquidation_reduceHealth() { // Should succeed, even though we are reducing health Test.expect(liqRes, Test.beSucceeded()) - // TODO(jord): validate post-liquidation balances + // Validate position balances post-liquidation + let collateralBalanceAfterLiq = getPositionBalance(pid: pid, vaultID: flowTokenIdentifier).balance + let debtBalanceAfterLiq = getPositionBalance(pid: pid, vaultID: moetIdentifier).balance + Test.assert(collateralBalanceAfterLiq == collateralBalancePreLiq - seizeAmount, message: "should lose exactly seized collateral") + Test.assert(debtBalanceAfterLiq == debtBalancePreLiq -repayAmount, message: "should lose exactly repaid debt") + + let liquidatorFlowBalance = getBalance(address: liquidator.address, vaultPublicPath: /public/flowTokenBalance) ?? 0.0 + Test.assert(liquidatorFlowBalance == seizeAmount, message: "liquidator should hold seized flow") // health after liquidation let hAfterLiq = getPositionHealth(pid: pid, beFailed: false) @@ -347,8 +357,72 @@ fun testManualLiquidation_increaseHealthBelowTarget() {} access(all) fun testManualLiquidation_liquidateToTarget() {} +/// Test the case where the liquidator provides a repayment vault of the collateral type instead of debt type. + +/// Test the case where the liquidator provides a repayment vault with different type than the debt type. access(all) -fun testManualLiquidation_repaymentVaultWrongType() {} +fun testManualLiquidation_repaymentVaultTypeMismatch() { + safeReset() + let pid: UInt64 = 0 + + // user setup + let user = Test.createAccount() + setupMoetVault(user, beFailed: false) + transferFlowTokens(to: user, amount: 1000.0) + + // open wrapped position and deposit via existing helper txs + // debt is MOET, collateral is FLOW + let openRes = _executeTransaction( + "./transactions/mock-flow-credit-market-consumer/create_wrapped_position.cdc", + [1000.0, /storage/flowTokenVault, true], + user + ) + Test.expect(openRes, Test.beSucceeded()) + + // health before price drop + let hBefore = getPositionHealth(pid: pid, beFailed: false) + let hBeforeUF = FlowCreditMarketMath.toUFix64Round(hBefore) + log("[LIQ] Health before price drop: raw=\(hBefore), approx=\(hBeforeUF)") + + // cause undercollateralization + let newPrice = 0.7 // $/FLOW + setMockOraclePrice(signer: Test.getAccount(0x0000000000000007), forTokenIdentifier: flowTokenIdentifier, price: newPrice) + let hAfterPrice = getPositionHealth(pid: pid, beFailed: false) + let hAfterPriceUF = FlowCreditMarketMath.toUFix64Round(hAfterPrice) + log("[LIQ] Health after price drop: raw=\(hAfterPrice), approx=\(hAfterPriceUF)") + + let debtPositionBalance = getPositionBalance(pid: pid, vaultID: moetIdentifier) + Test.assert(debtPositionBalance.direction == FlowCreditMarket.BalanceDirection.Debit) + var debtBalance = debtPositionBalance.balance + + // execute liquidation, attempting to pass in MockYieldToken instead of MOET + let liquidator = Test.createAccount() + setupMockYieldTokenVault(liquidator, beFailed: false) + mintMockYieldToken(signer: Test.getAccount(0x0000000000000007), to: liquidator.address, amount: 1000.0, beFailed: false) + + let liqBalance = getBalance(address: liquidator.address, vaultPublicPath: MockYieldToken.VaultPublicPath) ?? 0.0 + log("Liquidator mock balance after mint: \(liqBalance)") + + // Purport to repay MOET to seize FLOW, but we will actually pass in a MockYieldToken vault for repayment + let repayAmount = debtBalance + 0.001 + let seizeAmount = (repayAmount / newPrice) * 0.99 + let liqRes = _executeTransaction( + "../tests/transactions/flow-credit-market/pool-management/manual_liquidation_chosen_vault.cdc", + [pid, Type<@MOET.Vault>().identifier, mockYieldTokenIdentifier, flowTokenIdentifier, seizeAmount, repayAmount], + liquidator + ) + // Should fail because we are passing in a repayment vault with the wrong type + Test.expect(liqRes, Test.beFailed()) + log(liqRes.error) + Test.assertError(liqRes, errorMessage: "Repayment vault does not match debt type") + + // health after liquidation + let hAfterLiq = getPositionHealth(pid: pid, beFailed: false) + let hAfterLiqUF = FlowCreditMarketMath.toUFix64Round(hAfterLiq) + log("[LIQ] Health after liquidation: raw=\(hAfterLiq), approx=\(hAfterLiqUF)") + + Test.assert(hAfterLiq == hAfterPrice, message: "sanity check: health should not change after failed liquidation") +} access(all) fun testManualLiquidation_unsupportedDebtType() {} diff --git a/cadence/tests/test_helpers.cdc b/cadence/tests/test_helpers.cdc index a520949..243f34c 100644 --- a/cadence/tests/test_helpers.cdc +++ b/cadence/tests/test_helpers.cdc @@ -383,6 +383,18 @@ fun mintMoet(signer: Test.TestAccount, to: Address, amount: UFix64, beFailed: Bo Test.expect(mintRes, beFailed ? Test.beFailed() : Test.beSucceeded()) } +access(all) +fun setupMockYieldTokenVault(_ signer: Test.TestAccount, beFailed: Bool) { + let setupRes = _executeTransaction("../transactions/mocks/yieldtoken/setup_vault.cdc", [], signer) + Test.expect(setupRes, beFailed ? Test.beFailed() : Test.beSucceeded()) +} + +access(all) +fun mintMockYieldToken(signer: Test.TestAccount, to: Address, amount: UFix64, beFailed: Bool) { + let mintRes = _executeTransaction("../transactions/mocks/yieldtoken/mint.cdc", [to, amount], signer) + Test.expect(mintRes, beFailed ? Test.beFailed() : Test.beSucceeded()) +} + // Transfer Flow tokens from service account to recipient access(all) diff --git a/cadence/tests/transactions/flow-credit-market/pool-management/manual_liquidation_chosen_vault.cdc b/cadence/tests/transactions/flow-credit-market/pool-management/manual_liquidation_chosen_vault.cdc new file mode 100644 index 0000000..cef9ad9 --- /dev/null +++ b/cadence/tests/transactions/flow-credit-market/pool-management/manual_liquidation_chosen_vault.cdc @@ -0,0 +1,65 @@ +import "FungibleToken" +import "FungibleTokenMetadataViews" +import "MetadataViews" + +import "FlowCreditMarket" + +/// Attempt to liquidation a position by repaying `repayAmount`. +/// This TESTING-ONLY transaction allows specifying a different repayment vault type. +/// +/// debtVaultIdentifier: e.g., Type<@MOET.Vault>().identifier +/// seizeVaultIdentifier: e.g., Type<@FlowToken.Vault>().identifier +transaction(pid: UInt64, purportedDebtVaultIdentifier: String, actualDebtVaultIdentifier: String, seizeVaultIdentifier: String, seizeAmount: UFix64, repayAmount: UFix64) { + let pool: &FlowCreditMarket.Pool + let receiver: &{FungibleToken.Receiver} + let actualDebtType: Type + let purportedDebtType: Type + let seizeType: Type + let repay: @{FungibleToken.Vault} + + prepare(signer: auth(BorrowValue, SaveValue, IssueStorageCapabilityController, PublishCapability, UnpublishCapability) &Account) { + let protocolAddress = Type<@FlowCreditMarket.Pool>().address! + self.pool = getAccount(protocolAddress).capabilities.borrow<&FlowCreditMarket.Pool>(FlowCreditMarket.PoolPublicPath) + ?? panic("Could not borrow Pool at \(FlowCreditMarket.PoolPublicPath)") + + // Resolve types + self.actualDebtType = CompositeType(actualDebtVaultIdentifier) ?? panic("Invalid actualDebtVaultIdentifier: \(actualDebtVaultIdentifier)") + self.purportedDebtType = CompositeType(purportedDebtVaultIdentifier) ?? panic("Invalid purportedDebtVaultIdentifier: \(purportedDebtVaultIdentifier)") + self.seizeType = CompositeType(seizeVaultIdentifier) ?? panic("Invalid seizeVaultIdentifier: \(seizeVaultIdentifier)") + + // Get the path and type data for the provided token type identifier + let debtVaultData = MetadataViews.resolveContractViewFromTypeIdentifier( + resourceTypeIdentifier: actualDebtVaultIdentifier, + viewType: Type() + ) as? FungibleTokenMetadataViews.FTVaultData + ?? panic("Could not construct valid FT type and view from identifier \(actualDebtVaultIdentifier)") + + let seizeVaultData = MetadataViews.resolveContractViewFromTypeIdentifier( + resourceTypeIdentifier: seizeVaultIdentifier, + viewType: Type() + ) as? FungibleTokenMetadataViews.FTVaultData + ?? panic("Could not construct valid FT type and view from identifier \(seizeVaultIdentifier)") + + // Check if the service account has a vault for this token type at the correct storage path + let debtVaultRef = signer.storage.borrow(from: debtVaultData.storagePath) + ?? panic("no debt vault in storage at path \(debtVaultData.storagePath)") + assert(debtVaultRef.balance >= repayAmount, message: "Insufficient debt token \(debtVaultRef.getType().identifier) balance \(debtVaultRef.balance)<\(repayAmount)") + self.repay <- debtVaultRef.withdraw(amount: repayAmount) + + let seizeVaultRef = signer.capabilities.borrow<&{FungibleToken.Receiver}>(seizeVaultData.receiverPath) + ?? panic("no seize receiver in storage at path \(seizeVaultData.receiverPath)") + self.receiver = seizeVaultRef + } + + execute { + let seizedVault <- self.pool.manualLiquidation( + pid: pid, + debtType: self.purportedDebtType, + seizeType: self.seizeType, + seizeAmount: seizeAmount, + repayment: <-self.repay + ) + + self.receiver.deposit(from: <-seizedVault) + } +} diff --git a/cadence/transactions/flow-credit-market/pool-management/manual_liquidation.cdc b/cadence/transactions/flow-credit-market/pool-management/manual_liquidation.cdc index a98afcf..f0efd0a 100644 --- a/cadence/transactions/flow-credit-market/pool-management/manual_liquidation.cdc +++ b/cadence/transactions/flow-credit-market/pool-management/manual_liquidation.cdc @@ -1,10 +1,8 @@ import "FungibleToken" -import "FlowToken" import "FungibleTokenMetadataViews" import "MetadataViews" import "FlowCreditMarket" -import "MOET" /// Attempt to liquidation a position by repaying `repayAmount`. /// diff --git a/cadence/transactions/mocks/yieldtoken/mint.cdc b/cadence/transactions/mocks/yieldtoken/mint.cdc new file mode 100644 index 0000000..67cdca7 --- /dev/null +++ b/cadence/transactions/mocks/yieldtoken/mint.cdc @@ -0,0 +1,29 @@ +import "FungibleToken" + +import "MockYieldToken" + +/// Mints MockYieldToken using the Minter stored in the signer's account and deposits to the recipients MockYieldToken Vault. If the +/// recipient's MockYieldToken Vault is not configured with a public Capability or the signer does not have a MOET Minter +/// stored, the transaction will revert. +/// +/// @param to: The recipient's Flow address +/// @param amount: How many MockYieldToken tokens to mint to the recipient's account +/// +transaction(to: Address, amount: UFix64) { + + let receiver: &{FungibleToken.Vault} + let minter: &MockYieldToken.Minter + + prepare(signer: auth(BorrowValue) &Account) { + self.minter = signer.storage.borrow<&MockYieldToken.Minter>(from: MockYieldToken.AdminStoragePath) + ?? panic("Could not borrow reference to MOET Minter from signer's account at path \(MockYieldToken.AdminStoragePath)") + self.receiver = getAccount(to).capabilities.borrow<&{FungibleToken.Vault}>(MockYieldToken.VaultPublicPath) + ?? panic("Could not borrow reference to MOET Vault from recipient's account at path \(MockYieldToken.VaultPublicPath)") + } + + execute { + self.receiver.deposit( + from: <-self.minter.mintTokens(amount: amount) + ) + } +} diff --git a/cadence/transactions/mocks/yieldtoken/setup_vault.cdc b/cadence/transactions/mocks/yieldtoken/setup_vault.cdc new file mode 100644 index 0000000..bf57bfd --- /dev/null +++ b/cadence/transactions/mocks/yieldtoken/setup_vault.cdc @@ -0,0 +1,29 @@ +import "FungibleToken" + +import "MockYieldToken" + +/// Creates & stores a MockYieldToken Vault in the signer's account, also configuring its public Vault Capability +/// +transaction { + + prepare(signer: auth(BorrowValue, SaveValue, IssueStorageCapabilityController, PublishCapability, UnpublishCapability) &Account) { + // configure if nothing is found at canonical path + if signer.storage.type(at: MockYieldToken.VaultStoragePath) == nil { + // save the new vault + signer.storage.save(<-MockYieldToken.createEmptyVault(vaultType: Type<@MockYieldToken.Vault>()), to: MockYieldToken.VaultStoragePath) + // publish a public capability on the Vault + let cap = signer.capabilities.storage.issue<&{FungibleToken.Vault}>(MockYieldToken.VaultStoragePath) + signer.capabilities.unpublish(MockYieldToken.VaultPublicPath) + signer.capabilities.unpublish(MockYieldToken.ReceiverPublicPath) + signer.capabilities.publish(cap, at: MockYieldToken.VaultPublicPath) + signer.capabilities.publish(cap, at: MockYieldToken.ReceiverPublicPath) + // issue an authorized capability to initialize a CapabilityController on the account, but do not publish + signer.capabilities.storage.issue(MockYieldToken.VaultStoragePath) + } + + // ensure proper configuration + if signer.storage.type(at: MockYieldToken.VaultStoragePath) != Type<@MockYieldToken.Vault>(){ + panic("Could not configure MockYieldToken Vault at \(MockYieldToken.VaultStoragePath) - check for collision and try again") + } + } +} From 8e751cca5bd0222280420ff0b3bdf0f5257ce246 Mon Sep 17 00:00:00 2001 From: Jordan Schalm Date: Mon, 12 Jan 2026 15:49:37 -0800 Subject: [PATCH 30/34] add tests --- cadence/tests/liquidation_phase1_test.cdc | 204 ++++++++++++++++++++-- 1 file changed, 189 insertions(+), 15 deletions(-) diff --git a/cadence/tests/liquidation_phase1_test.cdc b/cadence/tests/liquidation_phase1_test.cdc index b298465..24e2c65 100644 --- a/cadence/tests/liquidation_phase1_test.cdc +++ b/cadence/tests/liquidation_phase1_test.cdc @@ -349,15 +349,59 @@ fun testManualLiquidation_reduceHealth() { Test.assert(hAfterLiq < hAfterPrice, message: "test expects health to decrease after liquidation") } -/// Should be able to liquidate to below target health while increasing health factor. +/// Test the case where the liquidator provides a repayment vault of the collateral type instead of debt type. access(all) -fun testManualLiquidation_increaseHealthBelowTarget() {} +fun testManualLiquidation_repaymentVaultCollateralType() { + safeReset() + let pid: UInt64 = 0 -/// Should be able to liquidate to exactly target health -access(all) -fun testManualLiquidation_liquidateToTarget() {} + // user setup + let user = Test.createAccount() + setupMoetVault(user, beFailed: false) + transferFlowTokens(to: user, amount: 1000.0) + + // open wrapped position and deposit via existing helper txs + // debt is MOET, collateral is FLOW + let openRes = _executeTransaction( + "./transactions/mock-flow-credit-market-consumer/create_wrapped_position.cdc", + [1000.0, /storage/flowTokenVault, true], + user + ) + Test.expect(openRes, Test.beSucceeded()) + + // health before price drop + let hBefore = getPositionHealth(pid: pid, beFailed: false) + let hBeforeUF = FlowCreditMarketMath.toUFix64Round(hBefore) + log("[LIQ] Health before price drop: raw=\(hBefore), approx=\(hBeforeUF)") + + // cause undercollateralization + let newPrice = 0.7 // $/FLOW + setMockOraclePrice(signer: Test.getAccount(0x0000000000000007), forTokenIdentifier: flowTokenIdentifier, price: newPrice) + let hAfterPrice = getPositionHealth(pid: pid, beFailed: false) + let hAfterPriceUF = FlowCreditMarketMath.toUFix64Round(hAfterPrice) + log("[LIQ] Health after price drop: raw=\(hAfterPrice), approx=\(hAfterPriceUF)") + + let debtPositionBalance = getPositionBalance(pid: pid, vaultID: moetIdentifier) + Test.assert(debtPositionBalance.direction == FlowCreditMarket.BalanceDirection.Debit) + var debtBalance = debtPositionBalance.balance + + // execute liquidation, attempting to pass in FLOW instead of MOET + let liquidator = Test.createAccount() + transferFlowTokens(to: liquidator, amount: 1000.0) + + // Purport to repay MOET to seize FLOW, but we will actually pass in a FLOW vault for repayment + let repayAmount = debtBalance + 0.001 + let seizeAmount = (repayAmount / newPrice) * 0.99 + let liqRes = _executeTransaction( + "../tests/transactions/flow-credit-market/pool-management/manual_liquidation_chosen_vault.cdc", + [pid, Type<@MOET.Vault>().identifier, flowTokenIdentifier, flowTokenIdentifier, seizeAmount, repayAmount], + liquidator + ) + // Should fail because we are passing in a repayment vault with the wrong type + Test.expect(liqRes, Test.beFailed()) + Test.assertError(liqRes, errorMessage: "Repayment vault does not match debt type") +} -/// Test the case where the liquidator provides a repayment vault of the collateral type instead of debt type. /// Test the case where the liquidator provides a repayment vault with different type than the debt type. access(all) @@ -413,28 +457,158 @@ fun testManualLiquidation_repaymentVaultTypeMismatch() { ) // Should fail because we are passing in a repayment vault with the wrong type Test.expect(liqRes, Test.beFailed()) - log(liqRes.error) Test.assertError(liqRes, errorMessage: "Repayment vault does not match debt type") +} - // health after liquidation - let hAfterLiq = getPositionHealth(pid: pid, beFailed: false) - let hAfterLiqUF = FlowCreditMarketMath.toUFix64Round(hAfterLiq) - log("[LIQ] Health after liquidation: raw=\(hAfterLiq), approx=\(hAfterLiqUF)") +// Test the case where a liquidator provides repayment in an unsupported debt type. +access(all) +fun testManualLiquidation_unsupportedDebtType() { + safeReset() + let pid: UInt64 = 0 - Test.assert(hAfterLiq == hAfterPrice, message: "sanity check: health should not change after failed liquidation") + // user setup + let user = Test.createAccount() + setupMoetVault(user, beFailed: false) + transferFlowTokens(to: user, amount: 1000.0) + + // open wrapped position and deposit via existing helper txs + // debt is MOET, collateral is FLOW + let openRes = _executeTransaction( + "./transactions/mock-flow-credit-market-consumer/create_wrapped_position.cdc", + [1000.0, /storage/flowTokenVault, true], + user + ) + Test.expect(openRes, Test.beSucceeded()) + + // health before price drop + let hBefore = getPositionHealth(pid: pid, beFailed: false) + let hBeforeUF = FlowCreditMarketMath.toUFix64Round(hBefore) + log("[LIQ] Health before price drop: raw=\(hBefore), approx=\(hBeforeUF)") + + // cause undercollateralization + let newPrice = 0.7 // $/FLOW + setMockOraclePrice(signer: Test.getAccount(0x0000000000000007), forTokenIdentifier: flowTokenIdentifier, price: newPrice) + let hAfterPrice = getPositionHealth(pid: pid, beFailed: false) + let hAfterPriceUF = FlowCreditMarketMath.toUFix64Round(hAfterPrice) + log("[LIQ] Health after price drop: raw=\(hAfterPrice), approx=\(hAfterPriceUF)") + + let debtPositionBalance = getPositionBalance(pid: pid, vaultID: moetIdentifier) + Test.assert(debtPositionBalance.direction == FlowCreditMarket.BalanceDirection.Debit) + var debtBalance = debtPositionBalance.balance + + // execute liquidation, attempting to pass in MockYieldToken instead of MOET + let liquidator = Test.createAccount() + setupMockYieldTokenVault(liquidator, beFailed: false) + mintMockYieldToken(signer: Test.getAccount(0x0000000000000007), to: liquidator.address, amount: 1000.0, beFailed: false) + + let liqBalance = getBalance(address: liquidator.address, vaultPublicPath: MockYieldToken.VaultPublicPath) ?? 0.0 + log("Liquidator mock balance after mint: \(liqBalance)") + + // Pass in MockYieldToken as repayment, an unsupported debt type + let repayAmount = debtBalance + 0.001 + let seizeAmount = (repayAmount / newPrice) * 0.99 + let liqRes = _executeTransaction( + "../tests/transactions/flow-credit-market/pool-management/manual_liquidation_chosen_vault.cdc", + [pid, mockYieldTokenIdentifier, mockYieldTokenIdentifier, flowTokenIdentifier, seizeAmount, repayAmount], + liquidator + ) + // Should fail because we are passing in a repayment vault with the wrong type + Test.expect(liqRes, Test.beFailed()) + Test.assertError(liqRes, errorMessage: "Debt token type unsupported") +} + +/// Test the case where a liquidator specifies an unsupported collateral type +access(all) +fun testManualLiquidation_unsupportedCollateralType() { + safeReset() + let pid: UInt64 = 0 + + // user setup + let user = Test.createAccount() + setupMoetVault(user, beFailed: false) + transferFlowTokens(to: user, amount: 1000.0) + + // open wrapped position and deposit via existing helper txs + // debt is MOET, collateral is FLOW + let openRes = _executeTransaction( + "./transactions/mock-flow-credit-market-consumer/create_wrapped_position.cdc", + [1000.0, /storage/flowTokenVault, true], + user + ) + Test.expect(openRes, Test.beSucceeded()) + + // health before price drop + let hBefore = getPositionHealth(pid: pid, beFailed: false) + let hBeforeUF = FlowCreditMarketMath.toUFix64Round(hBefore) + log("[LIQ] Health before price drop: raw=\(hBefore), approx=\(hBeforeUF)") + + // cause undercollateralization AND insolvency + let newPrice = 0.5 // $/FLOW + setMockOraclePrice(signer: Test.getAccount(0x0000000000000007), forTokenIdentifier: flowTokenIdentifier, price: newPrice) + let hAfterPrice = getPositionHealth(pid: pid, beFailed: false) + let hAfterPriceUF = FlowCreditMarketMath.toUFix64Round(hAfterPrice) + log("[LIQ] Health after price drop: raw=\(hAfterPrice), approx=\(hAfterPriceUF)") + + let collateralBalancePreLiq = getPositionBalance(pid: pid, vaultID: flowTokenIdentifier).balance + let debtBalancePreLiq = getPositionBalance(pid: pid, vaultID: moetIdentifier).balance + + // execute liquidation + let liquidator = Test.createAccount() + setupMoetVault(liquidator, beFailed: false) + let mintRes = _executeTransaction("../transactions/moet/mint_moet.cdc", [liquidator.address, 1000.0], Test.getAccount(0x0000000000000007)) + Test.expect(mintRes, Test.beSucceeded()) + + let liqBalance = getBalance(address: liquidator.address, vaultPublicPath: MOET.VaultPublicPath) ?? 0.0 + log("Liquidator MOET balance after mint: \(liqBalance)") + + // Repay MOET to seize FLOW. Choose seize amount above collateral balance + let seizeAmount = collateralBalancePreLiq - 0.01 + let repayAmount = seizeAmount * newPrice * 1.01 + let liqRes = _executeTransaction( + "../transactions/flow-credit-market/pool-management/manual_liquidation.cdc", + [pid, Type<@MOET.Vault>().identifier, mockYieldTokenIdentifier, seizeAmount, repayAmount], + liquidator + ) + // Should fail because we are specifying an unsupported collateral type (yield token) + Test.expect(liqRes, Test.beSucceeded()) + + // Validate position balances post-liquidation + let collateralBalanceAfterLiq = getPositionBalance(pid: pid, vaultID: flowTokenIdentifier).balance + let debtBalanceAfterLiq = getPositionBalance(pid: pid, vaultID: moetIdentifier).balance + Test.assert(collateralBalanceAfterLiq == collateralBalancePreLiq - seizeAmount, message: "should lose exactly seized collateral") + Test.assert(debtBalanceAfterLiq == debtBalancePreLiq -repayAmount, message: "should lose exactly repaid debt") + + let liquidatorFlowBalance = getBalance(address: liquidator.address, vaultPublicPath: /public/flowTokenBalance) ?? 0.0 + Test.assert(liquidatorFlowBalance == seizeAmount, message: "liquidator should hold seized flow") } +/// A liquidator specifies a supported debt type to repay, for an unhealthy position, but the position +/// does not have a debt balance of the specified type. access(all) -fun testManualLiquidation_unsupportedDebtType() {} +fun testManualLiquidation_supportedDebtTypeNotInPosition() {} +/// A liquidator specifies a supported collateral type to seize, for an unhealthy position, but the position +/// does not have a collateral balance of the specified type. access(all) -fun testManualLiquidation_unsupportedCollateralType() {} +fun testManualLiquidation_supportedCollateralTypeNotInPosition() {} +/// All liquidations should fail when liquidations are paused. access(all) fun testManualLiquidation_liquidationPaused() {} +/// All liquidations should fail during warmup period following liquidation pause. access(all) fun testManualLiquidation_liquidationWarmup() {} +/// Liquidations should fail if DEX price and oracle price diverge by too much. +access(all) +fun testManualLiquidation_dexOraclePriceDivergence() {} + +/// Should be able to liquidate to below target health while increasing health factor. access(all) -fun testManualLiquidation_dexOraclePriceDivergence() {} \ No newline at end of file +fun testManualLiquidation_increaseHealthBelowTarget() {} + +/// Should be able to liquidate to exactly target health +access(all) +fun testManualLiquidation_liquidateToTarget() {} + From 908b7051ab965b6fd587f659aed2aac3736f4718 Mon Sep 17 00:00:00 2001 From: Jordan Schalm Date: Mon, 12 Jan 2026 15:54:53 -0800 Subject: [PATCH 31/34] fix unsupported collateral type test --- cadence/tests/liquidation_phase1_test.cdc | 13 +++---------- 1 file changed, 3 insertions(+), 10 deletions(-) diff --git a/cadence/tests/liquidation_phase1_test.cdc b/cadence/tests/liquidation_phase1_test.cdc index 24e2c65..1c35a43 100644 --- a/cadence/tests/liquidation_phase1_test.cdc +++ b/cadence/tests/liquidation_phase1_test.cdc @@ -555,6 +555,7 @@ fun testManualLiquidation_unsupportedCollateralType() { // execute liquidation let liquidator = Test.createAccount() setupMoetVault(liquidator, beFailed: false) + setupMockYieldTokenVault(liquidator, beFailed: false) let mintRes = _executeTransaction("../transactions/moet/mint_moet.cdc", [liquidator.address, 1000.0], Test.getAccount(0x0000000000000007)) Test.expect(mintRes, Test.beSucceeded()) @@ -570,16 +571,8 @@ fun testManualLiquidation_unsupportedCollateralType() { liquidator ) // Should fail because we are specifying an unsupported collateral type (yield token) - Test.expect(liqRes, Test.beSucceeded()) - - // Validate position balances post-liquidation - let collateralBalanceAfterLiq = getPositionBalance(pid: pid, vaultID: flowTokenIdentifier).balance - let debtBalanceAfterLiq = getPositionBalance(pid: pid, vaultID: moetIdentifier).balance - Test.assert(collateralBalanceAfterLiq == collateralBalancePreLiq - seizeAmount, message: "should lose exactly seized collateral") - Test.assert(debtBalanceAfterLiq == debtBalancePreLiq -repayAmount, message: "should lose exactly repaid debt") - - let liquidatorFlowBalance = getBalance(address: liquidator.address, vaultPublicPath: /public/flowTokenBalance) ?? 0.0 - Test.assert(liquidatorFlowBalance == seizeAmount, message: "liquidator should hold seized flow") + Test.expect(liqRes, Test.beFailed()) + Test.assertError(liqRes, errorMessage: "Collateral token type unsupported") } /// A liquidator specifies a supported debt type to repay, for an unhealthy position, but the position From 6947c89b207a8e030b20aad3bfd18835076e082b Mon Sep 17 00:00:00 2001 From: Jordan Schalm Date: Tue, 13 Jan 2026 15:23:52 -0800 Subject: [PATCH 32/34] improve "supported, not in position" tests --- cadence/contracts/FlowCreditMarket.cdc | 27 +-- cadence/tests/liquidation_phase1_test.cdc | 212 +++++++++++++++------- 2 files changed, 167 insertions(+), 72 deletions(-) diff --git a/cadence/contracts/FlowCreditMarket.cdc b/cadence/contracts/FlowCreditMarket.cdc index b3d7919..1f5d779 100644 --- a/cadence/contracts/FlowCreditMarket.cdc +++ b/cadence/contracts/FlowCreditMarket.cdc @@ -977,18 +977,23 @@ access(all) contract FlowCreditMarket { } /// Returns the true balance of the given token in this position, accounting for interest. + /// Returns balance 0.0 if the position has no balance stored for the given token. access(all) fun trueBalance(ofToken: Type): UFix128 { - let balance = self.balances[ofToken]! - let tokenSnapshot = self.snapshots[ofToken]! - switch balance.direction { - case BalanceDirection.Debit: - return FlowCreditMarket.scaledBalanceToTrueBalance( - balance.scaledBalance, interestIndex: tokenSnapshot.debitIndex) - case BalanceDirection.Credit: - return FlowCreditMarket.scaledBalanceToTrueBalance( - balance.scaledBalance, interestIndex: tokenSnapshot.creditIndex) - } - panic("unreachable code") + if let balance = self.balances[ofToken] { + if let tokenSnapshot = self.snapshots[ofToken] { + switch balance.direction { + case BalanceDirection.Debit: + return FlowCreditMarket.scaledBalanceToTrueBalance( + balance.scaledBalance, interestIndex: tokenSnapshot.debitIndex) + case BalanceDirection.Credit: + return FlowCreditMarket.scaledBalanceToTrueBalance( + balance.scaledBalance, interestIndex: tokenSnapshot.creditIndex) + } + panic("unreachable") + } + } + // If the token doesn't exist in the position, the balance is 0 + return 0.0 } } diff --git a/cadence/tests/liquidation_phase1_test.cdc b/cadence/tests/liquidation_phase1_test.cdc index 1c35a43..844094f 100644 --- a/cadence/tests/liquidation_phase1_test.cdc +++ b/cadence/tests/liquidation_phase1_test.cdc @@ -54,12 +54,7 @@ fun testManualLiquidation_healthyPosition() { // open wrapped position and deposit via existing helper txs // debt is MOET, collateral is FLOW - let openRes = _executeTransaction( - "./transactions/mock-flow-credit-market-consumer/create_wrapped_position.cdc", - [1000.0, /storage/flowTokenVault, true], - user - ) - Test.expect(openRes, Test.beSucceeded()) + createWrappedPosition(signer: user, amount: 1000.0, vaultStoragePath: /storage/flowTokenVault, pushToDrawDownSink: true) // Log initial health let hBefore = getPositionHealth(pid: pid, beFailed: false) @@ -101,12 +96,7 @@ fun testManualLiquidation_liquidationExceedsTargetHealth() { // open wrapped position and deposit via existing helper txs // debt is MOET, collateral is FLOW - let openRes = _executeTransaction( - "./transactions/mock-flow-credit-market-consumer/create_wrapped_position.cdc", - [1000.0, /storage/flowTokenVault, true], - user - ) - Test.expect(openRes, Test.beSucceeded()) + createWrappedPosition(signer: user, amount: 1000.0, vaultStoragePath: /storage/flowTokenVault, pushToDrawDownSink: true) // health before price drop let hBefore = getPositionHealth(pid: pid, beFailed: false) @@ -163,12 +153,7 @@ fun testManualLiquidation_repayExceedsDebt() { // open wrapped position and deposit via existing helper txs // debt is MOET, collateral is FLOW - let openRes = _executeTransaction( - "./transactions/mock-flow-credit-market-consumer/create_wrapped_position.cdc", - [1000.0, /storage/flowTokenVault, true], - user - ) - Test.expect(openRes, Test.beSucceeded()) + createWrappedPosition(signer: user, amount: 1000.0, vaultStoragePath: /storage/flowTokenVault, pushToDrawDownSink: true) // health before price drop let hBefore = getPositionHealth(pid: pid, beFailed: false) @@ -228,12 +213,7 @@ fun testManualLiquidation_seizeExceedsCollateral() { // open wrapped position and deposit via existing helper txs // debt is MOET, collateral is FLOW - let openRes = _executeTransaction( - "./transactions/mock-flow-credit-market-consumer/create_wrapped_position.cdc", - [1000.0, /storage/flowTokenVault, true], - user - ) - Test.expect(openRes, Test.beSucceeded()) + createWrappedPosition(signer: user, amount: 1000.0, vaultStoragePath: /storage/flowTokenVault, pushToDrawDownSink: true) // health before price drop let hBefore = getPositionHealth(pid: pid, beFailed: false) @@ -291,12 +271,7 @@ fun testManualLiquidation_reduceHealth() { // open wrapped position and deposit via existing helper txs // debt is MOET, collateral is FLOW - let openRes = _executeTransaction( - "./transactions/mock-flow-credit-market-consumer/create_wrapped_position.cdc", - [1000.0, /storage/flowTokenVault, true], - user - ) - Test.expect(openRes, Test.beSucceeded()) + createWrappedPosition(signer: user, amount: 1000.0, vaultStoragePath: /storage/flowTokenVault, pushToDrawDownSink: true) // health before price drop let hBefore = getPositionHealth(pid: pid, beFailed: false) @@ -362,12 +337,7 @@ fun testManualLiquidation_repaymentVaultCollateralType() { // open wrapped position and deposit via existing helper txs // debt is MOET, collateral is FLOW - let openRes = _executeTransaction( - "./transactions/mock-flow-credit-market-consumer/create_wrapped_position.cdc", - [1000.0, /storage/flowTokenVault, true], - user - ) - Test.expect(openRes, Test.beSucceeded()) + createWrappedPosition(signer: user, amount: 1000.0, vaultStoragePath: /storage/flowTokenVault, pushToDrawDownSink: true) // health before price drop let hBefore = getPositionHealth(pid: pid, beFailed: false) @@ -416,12 +386,7 @@ fun testManualLiquidation_repaymentVaultTypeMismatch() { // open wrapped position and deposit via existing helper txs // debt is MOET, collateral is FLOW - let openRes = _executeTransaction( - "./transactions/mock-flow-credit-market-consumer/create_wrapped_position.cdc", - [1000.0, /storage/flowTokenVault, true], - user - ) - Test.expect(openRes, Test.beSucceeded()) + createWrappedPosition(signer: user, amount: 1000.0, vaultStoragePath: /storage/flowTokenVault, pushToDrawDownSink: true) // health before price drop let hBefore = getPositionHealth(pid: pid, beFailed: false) @@ -473,12 +438,7 @@ fun testManualLiquidation_unsupportedDebtType() { // open wrapped position and deposit via existing helper txs // debt is MOET, collateral is FLOW - let openRes = _executeTransaction( - "./transactions/mock-flow-credit-market-consumer/create_wrapped_position.cdc", - [1000.0, /storage/flowTokenVault, true], - user - ) - Test.expect(openRes, Test.beSucceeded()) + createWrappedPosition(signer: user, amount: 1000.0, vaultStoragePath: /storage/flowTokenVault, pushToDrawDownSink: true) // health before price drop let hBefore = getPositionHealth(pid: pid, beFailed: false) @@ -530,20 +490,15 @@ fun testManualLiquidation_unsupportedCollateralType() { // open wrapped position and deposit via existing helper txs // debt is MOET, collateral is FLOW - let openRes = _executeTransaction( - "./transactions/mock-flow-credit-market-consumer/create_wrapped_position.cdc", - [1000.0, /storage/flowTokenVault, true], - user - ) - Test.expect(openRes, Test.beSucceeded()) + createWrappedPosition(signer: user, amount: 1000.0, vaultStoragePath: /storage/flowTokenVault, pushToDrawDownSink: true) // health before price drop let hBefore = getPositionHealth(pid: pid, beFailed: false) let hBeforeUF = FlowCreditMarketMath.toUFix64Round(hBefore) log("[LIQ] Health before price drop: raw=\(hBefore), approx=\(hBeforeUF)") - // cause undercollateralization AND insolvency - let newPrice = 0.5 // $/FLOW + // cause undercollateralization + let newPrice = 0.7 // $/FLOW setMockOraclePrice(signer: Test.getAccount(0x0000000000000007), forTokenIdentifier: flowTokenIdentifier, price: newPrice) let hAfterPrice = getPositionHealth(pid: pid, beFailed: false) let hAfterPriceUF = FlowCreditMarketMath.toUFix64Round(hAfterPrice) @@ -575,15 +530,150 @@ fun testManualLiquidation_unsupportedCollateralType() { Test.assertError(liqRes, errorMessage: "Collateral token type unsupported") } +/// A liquidator specifies a supported collateral type to seize, for an unhealthy position, but the position +/// does not have a collateral balance of the specified type. +access(all) +fun testManualLiquidation_supportedDebtTypeNotInPosition() { + safeReset() + let protocolAccount = Test.getAccount(0x0000000000000007) + + // Add MockYieldToken as a supported token type + setMockOraclePrice(signer: protocolAccount, forTokenIdentifier: mockYieldTokenIdentifier, price: 1.0) + addSupportedTokenZeroRateCurve( + signer: protocolAccount, + tokenTypeIdentifier: mockYieldTokenIdentifier, + collateralFactor: 0.8, + borrowFactor: 1.0, + depositRate: 1_000_000.0, + depositCapacityCap: 1_000_000.0 + ) + + // user1 setup - deposits FLOW + let user1 = Test.createAccount() + setupMoetVault(user1, beFailed: false) + transferFlowTokens(to: user1, amount: 1000.0) + + // user1 opens wrapped position with FLOW collateral + // debt is MOET, collateral is FLOW + let pid1: UInt64 = 0 + createWrappedPosition(signer: user1, amount: 1000.0, vaultStoragePath: /storage/flowTokenVault, pushToDrawDownSink: true) + + // user2 setup - deposits MockYieldToken + let user2 = Test.createAccount() + setupMoetVault(user2, beFailed: false) + setupMockYieldTokenVault(user2, beFailed: false) + mintMockYieldToken(signer: protocolAccount, to: user2.address, amount: 1000.0, beFailed: false) + + // user2 opens wrapped position with MockYieldToken collateral + let pid2: UInt64 = 1 + createWrappedPosition(signer: user2, amount: 1000.0, vaultStoragePath: MockYieldToken.VaultStoragePath, pushToDrawDownSink: true) + + // health before price drop for user1 + let hBefore = getPositionHealth(pid: pid1, beFailed: false) + let hBeforeUF = FlowCreditMarketMath.toUFix64Round(hBefore) + log("[LIQ] User1 health before price drop: raw=\(hBefore), approx=\(hBeforeUF)") + + // cause undercollateralization for user1 by dropping FLOW price + let newPrice = 0.7 // $/FLOW + setMockOraclePrice(signer: protocolAccount, forTokenIdentifier: flowTokenIdentifier, price: newPrice) + let hAfterPrice = getPositionHealth(pid: pid1, beFailed: false) + let hAfterPriceUF = FlowCreditMarketMath.toUFix64Round(hAfterPrice) + log("[LIQ] User1 health after price drop: raw=\(hAfterPrice), approx=\(hAfterPriceUF)") + + // execute liquidation + let liquidator = Test.createAccount() + setupMockYieldTokenVault(liquidator, beFailed: false) + mintMockYieldToken(signer: protocolAccount, to: liquidator.address, amount: 1000.0, beFailed: false) + + let liqBalance = getBalance(address: liquidator.address, vaultPublicPath: MOET.VaultPublicPath) ?? 0.0 + log("Liquidator MOET balance after mint: \(liqBalance)") + + // Try to liquidate user1's position but repay MockYieldToken instead of MOET + // user1 has no MockYieldToken debt balance + let seizeAmount = 0.01 + let repayAmount = 100.0 + let liqRes = _executeTransaction( + "../transactions/flow-credit-market/pool-management/manual_liquidation.cdc", + [pid1, mockYieldTokenIdentifier, flowTokenIdentifier, seizeAmount, repayAmount], + liquidator + ) + // Should fail because user1's position doesn't have MockYieldToken collateral + Test.expect(liqRes, Test.beFailed()) + Test.assertError(liqRes, errorMessage: "Cannot repay more debt than is in position") +} + /// A liquidator specifies a supported debt type to repay, for an unhealthy position, but the position /// does not have a debt balance of the specified type. access(all) -fun testManualLiquidation_supportedDebtTypeNotInPosition() {} +fun testManualLiquidation_supportedCollateralTypeNotInPosition() { + safeReset() + let protocolAccount = Test.getAccount(0x0000000000000007) -/// A liquidator specifies a supported collateral type to seize, for an unhealthy position, but the position -/// does not have a collateral balance of the specified type. -access(all) -fun testManualLiquidation_supportedCollateralTypeNotInPosition() {} + // Add MockYieldToken as a supported token (can be used as collateral or debt) + setMockOraclePrice(signer: protocolAccount, forTokenIdentifier: mockYieldTokenIdentifier, price: 1.0) + addSupportedTokenZeroRateCurve( + signer: protocolAccount, + tokenTypeIdentifier: mockYieldTokenIdentifier, + collateralFactor: 0.8, + borrowFactor: 1.0, + depositRate: 1_000_000.0, + depositCapacityCap: 1_000_000.0 + ) + + // user1 setup - deposits FLOW, borrows MOET + let user1 = Test.createAccount() + setupMoetVault(user1, beFailed: false) + transferFlowTokens(to: user1, amount: 1000.0) + + // user1 opens wrapped position with FLOW collateral, MOET debt + let pid1: UInt64 = 0 + createWrappedPosition(signer: user1, amount: 1000.0, vaultStoragePath: /storage/flowTokenVault, pushToDrawDownSink: true) + + // user2 setup - deposits MockYieldToken, borrows MOET + let user2 = Test.createAccount() + setupMoetVault(user2, beFailed: false) + setupMockYieldTokenVault(user2, beFailed: false) + mintMockYieldToken(signer: protocolAccount, to: user2.address, amount: 1000.0, beFailed: false) + + // user2 opens wrapped position with MockYieldToken collateral + let pid2: UInt64 = 1 + createWrappedPosition(signer: user2, amount: 1000.0, vaultStoragePath: MockYieldToken.VaultStoragePath, pushToDrawDownSink: true) + + // health before price drop for user1 + let hBefore = getPositionHealth(pid: pid1, beFailed: false) + let hBeforeUF = FlowCreditMarketMath.toUFix64Round(hBefore) + log("[LIQ] User1 health before price drop: raw=\(hBefore), approx=\(hBeforeUF)") + + // cause undercollateralization for user1 by dropping FLOW price + let newPrice = 0.5 // $/FLOW + setMockOraclePrice(signer: protocolAccount, forTokenIdentifier: flowTokenIdentifier, price: newPrice) + let hAfterPrice = getPositionHealth(pid: pid1, beFailed: false) + let hAfterPriceUF = FlowCreditMarketMath.toUFix64Round(hAfterPrice) + log("[LIQ] User1 health after price drop: raw=\(hAfterPrice), approx=\(hAfterPriceUF)") + + // execute liquidation + let liquidator = Test.createAccount() + setupMoetVault(liquidator, beFailed: false) + setupMockYieldTokenVault(liquidator, beFailed: false) + mintMoet(signer: protocolAccount, to: liquidator.address, amount: 1000.0, beFailed: false) + + let liqBalance = getBalance(address: liquidator.address, vaultPublicPath: MockYieldToken.VaultPublicPath) ?? 0.0 + log("Liquidator MockYieldToken balance after mint: \(liqBalance)") + + // Try to liquidate user1's position by repaying MockYieldToken debt + // User1 only has MOET debt, not MockYieldToken debt + let seizeAmount = 0.01 + let repayAmount = 100.0 + let liqRes = _executeTransaction( + "../transactions/flow-credit-market/pool-management/manual_liquidation.cdc", + [pid1, Type<@MOET.Vault>().identifier, mockYieldTokenIdentifier, seizeAmount, repayAmount], + liquidator + ) + // Should fail because user1's position doesn't have MockYieldToken debt + Test.expect(liqRes, Test.beFailed()) + log(liqRes.error) + Test.assertError(liqRes, errorMessage: "Cannot seize more collateral than is in position") +} /// All liquidations should fail when liquidations are paused. access(all) From 1b34a81dfe2e616314aca0e48e8780e53c009757 Mon Sep 17 00:00:00 2001 From: Jordan Schalm Date: Tue, 13 Jan 2026 16:45:19 -0800 Subject: [PATCH 33/34] remove unneeded logs --- cadence/tests/liquidation_phase1_test.cdc | 83 +++-------------------- 1 file changed, 8 insertions(+), 75 deletions(-) diff --git a/cadence/tests/liquidation_phase1_test.cdc b/cadence/tests/liquidation_phase1_test.cdc index 844094f..8577360 100644 --- a/cadence/tests/liquidation_phase1_test.cdc +++ b/cadence/tests/liquidation_phase1_test.cdc @@ -57,19 +57,15 @@ fun testManualLiquidation_healthyPosition() { createWrappedPosition(signer: user, amount: 1000.0, vaultStoragePath: /storage/flowTokenVault, pushToDrawDownSink: true) // Log initial health - let hBefore = getPositionHealth(pid: pid, beFailed: false) - let hBeforeUF = FlowCreditMarketMath.toUFix64Round(hBefore) - log("[LIQ] Health before price drop: raw=\(hBefore), approx=\(hBeforeUF)") - Test.assert(hBefore >= 1.0, message: "initial position state is unhealthy") + let health = getPositionHealth(pid: pid, beFailed: false) + Test.assert(health >= 1.0, message: "initial position state is unhealthy") // execute liquidation let liquidator = Test.createAccount() setupMoetVault(liquidator, beFailed: false) - let mintRes = _executeTransaction("../transactions/moet/mint_moet.cdc", [liquidator.address, 1000.0], Test.getAccount(0x0000000000000007)) - Test.expect(mintRes, Test.beSucceeded()) + mintMoet(signer: Test.getAccount(0x0000000000000007), to: liquidator.address, amount: 1000.0, beFailed: false) let liqBalance = getBalance(address: liquidator.address, vaultPublicPath: MOET.VaultPublicPath) ?? 0.0 - log("Liquidator MOET balance after mint: \(liqBalance)") // Repay MOET to seize FLOW let repayAmount = 2.0 @@ -100,24 +96,18 @@ fun testManualLiquidation_liquidationExceedsTargetHealth() { // health before price drop let hBefore = getPositionHealth(pid: pid, beFailed: false) - let hBeforeUF = FlowCreditMarketMath.toUFix64Round(hBefore) - log("[LIQ] Health before price drop: raw=\(hBefore), approx=\(hBeforeUF)") // cause undercollateralization let newPrice = 0.7 // $/FLOW setMockOraclePrice(signer: Test.getAccount(0x0000000000000007), forTokenIdentifier: flowTokenIdentifier, price: newPrice) let hAfterPrice = getPositionHealth(pid: pid, beFailed: false) - let hAfterPriceUF = FlowCreditMarketMath.toUFix64Round(hAfterPrice) - log("[LIQ] Health after price drop: raw=\(hAfterPrice), approx=\(hAfterPriceUF)") // execute liquidation let liquidator = Test.createAccount() setupMoetVault(liquidator, beFailed: false) - let mintRes = _executeTransaction("../transactions/moet/mint_moet.cdc", [liquidator.address, 1000.0], Test.getAccount(0x0000000000000007)) - Test.expect(mintRes, Test.beSucceeded()) + mintMoet(signer: Test.getAccount(0x0000000000000007), to: liquidator.address, amount: 1000.0, beFailed: false) let liqBalance = getBalance(address: liquidator.address, vaultPublicPath: MOET.VaultPublicPath) ?? 0.0 - log("Liquidator MOET balance after mint: \(liqBalance)") // Repay MOET to seize FLOW. // TODO(jord): add helper to compute health boundaries given best acceptable price, then test boundaries @@ -134,8 +124,6 @@ fun testManualLiquidation_liquidationExceedsTargetHealth() { // health after liquidation let hAfterLiq = getPositionHealth(pid: pid, beFailed: false) - let hAfterLiqUF = FlowCreditMarketMath.toUFix64Round(hAfterLiq) - log("[LIQ] Health after liquidation: raw=\(hAfterLiq), approx=\(hAfterLiqUF)") Test.assert(hAfterLiq == hAfterPrice, message: "sanity check: health should not change after failed liquidation") } @@ -157,15 +145,11 @@ fun testManualLiquidation_repayExceedsDebt() { // health before price drop let hBefore = getPositionHealth(pid: pid, beFailed: false) - let hBeforeUF = FlowCreditMarketMath.toUFix64Round(hBefore) - log("[LIQ] Health before price drop: raw=\(hBefore), approx=\(hBeforeUF)") // cause undercollateralization let newPrice = 0.7 // $/FLOW setMockOraclePrice(signer: Test.getAccount(0x0000000000000007), forTokenIdentifier: flowTokenIdentifier, price: newPrice) let hAfterPrice = getPositionHealth(pid: pid, beFailed: false) - let hAfterPriceUF = FlowCreditMarketMath.toUFix64Round(hAfterPrice) - log("[LIQ] Health after price drop: raw=\(hAfterPrice), approx=\(hAfterPriceUF)") let debtPositionBalance = getPositionBalance(pid: pid, vaultID: moetIdentifier) Test.assert(debtPositionBalance.direction == FlowCreditMarket.BalanceDirection.Debit) @@ -174,11 +158,9 @@ fun testManualLiquidation_repayExceedsDebt() { // execute liquidation let liquidator = Test.createAccount() setupMoetVault(liquidator, beFailed: false) - let mintRes = _executeTransaction("../transactions/moet/mint_moet.cdc", [liquidator.address, 1000.0], Test.getAccount(0x0000000000000007)) - Test.expect(mintRes, Test.beSucceeded()) + mintMoet(signer: Test.getAccount(0x0000000000000007), to: liquidator.address, amount: 1000.0, beFailed: false) let liqBalance = getBalance(address: liquidator.address, vaultPublicPath: MOET.VaultPublicPath) ?? 0.0 - log("Liquidator MOET balance after mint: \(liqBalance)") // Repay MOET to seize FLOW. Choose repay amount above debt balance let repayAmount = debtBalance + 0.001 @@ -194,8 +176,6 @@ fun testManualLiquidation_repayExceedsDebt() { // health after liquidation let hAfterLiq = getPositionHealth(pid: pid, beFailed: false) - let hAfterLiqUF = FlowCreditMarketMath.toUFix64Round(hAfterLiq) - log("[LIQ] Health after liquidation: raw=\(hAfterLiq), approx=\(hAfterLiqUF)") Test.assert(hAfterLiq == hAfterPrice, message: "sanity check: health should not change after failed liquidation") } @@ -217,26 +197,20 @@ fun testManualLiquidation_seizeExceedsCollateral() { // health before price drop let hBefore = getPositionHealth(pid: pid, beFailed: false) - let hBeforeUF = FlowCreditMarketMath.toUFix64Round(hBefore) - log("[LIQ] Health before price drop: raw=\(hBefore), approx=\(hBeforeUF)") // cause undercollateralization AND insolvency let newPrice = 0.5 // $/FLOW setMockOraclePrice(signer: Test.getAccount(0x0000000000000007), forTokenIdentifier: flowTokenIdentifier, price: newPrice) let hAfterPrice = getPositionHealth(pid: pid, beFailed: false) - let hAfterPriceUF = FlowCreditMarketMath.toUFix64Round(hAfterPrice) - log("[LIQ] Health after price drop: raw=\(hAfterPrice), approx=\(hAfterPriceUF)") let collateralBalance = getPositionBalance(pid: pid, vaultID: flowTokenIdentifier).balance // execute liquidation let liquidator = Test.createAccount() setupMoetVault(liquidator, beFailed: false) - let mintRes = _executeTransaction("../transactions/moet/mint_moet.cdc", [liquidator.address, 1000.0], Test.getAccount(0x0000000000000007)) - Test.expect(mintRes, Test.beSucceeded()) + mintMoet(signer: Test.getAccount(0x0000000000000007), to: liquidator.address, amount: 1000.0, beFailed: false) let liqBalance = getBalance(address: liquidator.address, vaultPublicPath: MOET.VaultPublicPath) ?? 0.0 - log("Liquidator MOET balance after mint: \(liqBalance)") // Repay MOET to seize FLOW. Choose seize amount above collateral balance let seizeAmount = collateralBalance + 0.001 @@ -252,8 +226,6 @@ fun testManualLiquidation_seizeExceedsCollateral() { // health after liquidation let hAfterLiq = getPositionHealth(pid: pid, beFailed: false) - let hAfterLiqUF = FlowCreditMarketMath.toUFix64Round(hAfterLiq) - log("[LIQ] Health after liquidation: raw=\(hAfterLiq), approx=\(hAfterLiqUF)") Test.assert(hAfterLiq == hAfterPrice, message: "sanity check: health should not change after failed liquidation") } @@ -275,15 +247,11 @@ fun testManualLiquidation_reduceHealth() { // health before price drop let hBefore = getPositionHealth(pid: pid, beFailed: false) - let hBeforeUF = FlowCreditMarketMath.toUFix64Round(hBefore) - log("[LIQ] Health before price drop: raw=\(hBefore), approx=\(hBeforeUF)") // cause undercollateralization AND insolvency let newPrice = 0.5 // $/FLOW setMockOraclePrice(signer: Test.getAccount(0x0000000000000007), forTokenIdentifier: flowTokenIdentifier, price: newPrice) let hAfterPrice = getPositionHealth(pid: pid, beFailed: false) - let hAfterPriceUF = FlowCreditMarketMath.toUFix64Round(hAfterPrice) - log("[LIQ] Health after price drop: raw=\(hAfterPrice), approx=\(hAfterPriceUF)") let collateralBalancePreLiq = getPositionBalance(pid: pid, vaultID: flowTokenIdentifier).balance let debtBalancePreLiq = getPositionBalance(pid: pid, vaultID: moetIdentifier).balance @@ -291,11 +259,9 @@ fun testManualLiquidation_reduceHealth() { // execute liquidation let liquidator = Test.createAccount() setupMoetVault(liquidator, beFailed: false) - let mintRes = _executeTransaction("../transactions/moet/mint_moet.cdc", [liquidator.address, 1000.0], Test.getAccount(0x0000000000000007)) - Test.expect(mintRes, Test.beSucceeded()) + mintMoet(signer: Test.getAccount(0x0000000000000007), to: liquidator.address, amount: 1000.0, beFailed: false) let liqBalance = getBalance(address: liquidator.address, vaultPublicPath: MOET.VaultPublicPath) ?? 0.0 - log("Liquidator MOET balance after mint: \(liqBalance)") // Repay MOET to seize FLOW. Choose seize amount above collateral balance let seizeAmount = collateralBalancePreLiq - 0.01 @@ -319,8 +285,6 @@ fun testManualLiquidation_reduceHealth() { // health after liquidation let hAfterLiq = getPositionHealth(pid: pid, beFailed: false) - let hAfterLiqUF = FlowCreditMarketMath.toUFix64Round(hAfterLiq) - log("[LIQ] Health after liquidation: raw=\(hAfterLiq), approx=\(hAfterLiqUF)") Test.assert(hAfterLiq < hAfterPrice, message: "test expects health to decrease after liquidation") } @@ -341,15 +305,11 @@ fun testManualLiquidation_repaymentVaultCollateralType() { // health before price drop let hBefore = getPositionHealth(pid: pid, beFailed: false) - let hBeforeUF = FlowCreditMarketMath.toUFix64Round(hBefore) - log("[LIQ] Health before price drop: raw=\(hBefore), approx=\(hBeforeUF)") // cause undercollateralization let newPrice = 0.7 // $/FLOW setMockOraclePrice(signer: Test.getAccount(0x0000000000000007), forTokenIdentifier: flowTokenIdentifier, price: newPrice) let hAfterPrice = getPositionHealth(pid: pid, beFailed: false) - let hAfterPriceUF = FlowCreditMarketMath.toUFix64Round(hAfterPrice) - log("[LIQ] Health after price drop: raw=\(hAfterPrice), approx=\(hAfterPriceUF)") let debtPositionBalance = getPositionBalance(pid: pid, vaultID: moetIdentifier) Test.assert(debtPositionBalance.direction == FlowCreditMarket.BalanceDirection.Debit) @@ -390,15 +350,11 @@ fun testManualLiquidation_repaymentVaultTypeMismatch() { // health before price drop let hBefore = getPositionHealth(pid: pid, beFailed: false) - let hBeforeUF = FlowCreditMarketMath.toUFix64Round(hBefore) - log("[LIQ] Health before price drop: raw=\(hBefore), approx=\(hBeforeUF)") // cause undercollateralization let newPrice = 0.7 // $/FLOW setMockOraclePrice(signer: Test.getAccount(0x0000000000000007), forTokenIdentifier: flowTokenIdentifier, price: newPrice) let hAfterPrice = getPositionHealth(pid: pid, beFailed: false) - let hAfterPriceUF = FlowCreditMarketMath.toUFix64Round(hAfterPrice) - log("[LIQ] Health after price drop: raw=\(hAfterPrice), approx=\(hAfterPriceUF)") let debtPositionBalance = getPositionBalance(pid: pid, vaultID: moetIdentifier) Test.assert(debtPositionBalance.direction == FlowCreditMarket.BalanceDirection.Debit) @@ -410,7 +366,6 @@ fun testManualLiquidation_repaymentVaultTypeMismatch() { mintMockYieldToken(signer: Test.getAccount(0x0000000000000007), to: liquidator.address, amount: 1000.0, beFailed: false) let liqBalance = getBalance(address: liquidator.address, vaultPublicPath: MockYieldToken.VaultPublicPath) ?? 0.0 - log("Liquidator mock balance after mint: \(liqBalance)") // Purport to repay MOET to seize FLOW, but we will actually pass in a MockYieldToken vault for repayment let repayAmount = debtBalance + 0.001 @@ -442,15 +397,11 @@ fun testManualLiquidation_unsupportedDebtType() { // health before price drop let hBefore = getPositionHealth(pid: pid, beFailed: false) - let hBeforeUF = FlowCreditMarketMath.toUFix64Round(hBefore) - log("[LIQ] Health before price drop: raw=\(hBefore), approx=\(hBeforeUF)") // cause undercollateralization let newPrice = 0.7 // $/FLOW setMockOraclePrice(signer: Test.getAccount(0x0000000000000007), forTokenIdentifier: flowTokenIdentifier, price: newPrice) let hAfterPrice = getPositionHealth(pid: pid, beFailed: false) - let hAfterPriceUF = FlowCreditMarketMath.toUFix64Round(hAfterPrice) - log("[LIQ] Health after price drop: raw=\(hAfterPrice), approx=\(hAfterPriceUF)") let debtPositionBalance = getPositionBalance(pid: pid, vaultID: moetIdentifier) Test.assert(debtPositionBalance.direction == FlowCreditMarket.BalanceDirection.Debit) @@ -462,7 +413,6 @@ fun testManualLiquidation_unsupportedDebtType() { mintMockYieldToken(signer: Test.getAccount(0x0000000000000007), to: liquidator.address, amount: 1000.0, beFailed: false) let liqBalance = getBalance(address: liquidator.address, vaultPublicPath: MockYieldToken.VaultPublicPath) ?? 0.0 - log("Liquidator mock balance after mint: \(liqBalance)") // Pass in MockYieldToken as repayment, an unsupported debt type let repayAmount = debtBalance + 0.001 @@ -494,15 +444,11 @@ fun testManualLiquidation_unsupportedCollateralType() { // health before price drop let hBefore = getPositionHealth(pid: pid, beFailed: false) - let hBeforeUF = FlowCreditMarketMath.toUFix64Round(hBefore) - log("[LIQ] Health before price drop: raw=\(hBefore), approx=\(hBeforeUF)") // cause undercollateralization let newPrice = 0.7 // $/FLOW setMockOraclePrice(signer: Test.getAccount(0x0000000000000007), forTokenIdentifier: flowTokenIdentifier, price: newPrice) let hAfterPrice = getPositionHealth(pid: pid, beFailed: false) - let hAfterPriceUF = FlowCreditMarketMath.toUFix64Round(hAfterPrice) - log("[LIQ] Health after price drop: raw=\(hAfterPrice), approx=\(hAfterPriceUF)") let collateralBalancePreLiq = getPositionBalance(pid: pid, vaultID: flowTokenIdentifier).balance let debtBalancePreLiq = getPositionBalance(pid: pid, vaultID: moetIdentifier).balance @@ -511,11 +457,9 @@ fun testManualLiquidation_unsupportedCollateralType() { let liquidator = Test.createAccount() setupMoetVault(liquidator, beFailed: false) setupMockYieldTokenVault(liquidator, beFailed: false) - let mintRes = _executeTransaction("../transactions/moet/mint_moet.cdc", [liquidator.address, 1000.0], Test.getAccount(0x0000000000000007)) - Test.expect(mintRes, Test.beSucceeded()) + mintMoet(signer: Test.getAccount(0x0000000000000007), to: liquidator.address, amount: 1000.0, beFailed: false) let liqBalance = getBalance(address: liquidator.address, vaultPublicPath: MOET.VaultPublicPath) ?? 0.0 - log("Liquidator MOET balance after mint: \(liqBalance)") // Repay MOET to seize FLOW. Choose seize amount above collateral balance let seizeAmount = collateralBalancePreLiq - 0.01 @@ -570,15 +514,11 @@ fun testManualLiquidation_supportedDebtTypeNotInPosition() { // health before price drop for user1 let hBefore = getPositionHealth(pid: pid1, beFailed: false) - let hBeforeUF = FlowCreditMarketMath.toUFix64Round(hBefore) - log("[LIQ] User1 health before price drop: raw=\(hBefore), approx=\(hBeforeUF)") // cause undercollateralization for user1 by dropping FLOW price let newPrice = 0.7 // $/FLOW setMockOraclePrice(signer: protocolAccount, forTokenIdentifier: flowTokenIdentifier, price: newPrice) let hAfterPrice = getPositionHealth(pid: pid1, beFailed: false) - let hAfterPriceUF = FlowCreditMarketMath.toUFix64Round(hAfterPrice) - log("[LIQ] User1 health after price drop: raw=\(hAfterPrice), approx=\(hAfterPriceUF)") // execute liquidation let liquidator = Test.createAccount() @@ -586,7 +526,6 @@ fun testManualLiquidation_supportedDebtTypeNotInPosition() { mintMockYieldToken(signer: protocolAccount, to: liquidator.address, amount: 1000.0, beFailed: false) let liqBalance = getBalance(address: liquidator.address, vaultPublicPath: MOET.VaultPublicPath) ?? 0.0 - log("Liquidator MOET balance after mint: \(liqBalance)") // Try to liquidate user1's position but repay MockYieldToken instead of MOET // user1 has no MockYieldToken debt balance @@ -641,15 +580,11 @@ fun testManualLiquidation_supportedCollateralTypeNotInPosition() { // health before price drop for user1 let hBefore = getPositionHealth(pid: pid1, beFailed: false) - let hBeforeUF = FlowCreditMarketMath.toUFix64Round(hBefore) - log("[LIQ] User1 health before price drop: raw=\(hBefore), approx=\(hBeforeUF)") // cause undercollateralization for user1 by dropping FLOW price let newPrice = 0.5 // $/FLOW setMockOraclePrice(signer: protocolAccount, forTokenIdentifier: flowTokenIdentifier, price: newPrice) let hAfterPrice = getPositionHealth(pid: pid1, beFailed: false) - let hAfterPriceUF = FlowCreditMarketMath.toUFix64Round(hAfterPrice) - log("[LIQ] User1 health after price drop: raw=\(hAfterPrice), approx=\(hAfterPriceUF)") // execute liquidation let liquidator = Test.createAccount() @@ -658,7 +593,6 @@ fun testManualLiquidation_supportedCollateralTypeNotInPosition() { mintMoet(signer: protocolAccount, to: liquidator.address, amount: 1000.0, beFailed: false) let liqBalance = getBalance(address: liquidator.address, vaultPublicPath: MockYieldToken.VaultPublicPath) ?? 0.0 - log("Liquidator MockYieldToken balance after mint: \(liqBalance)") // Try to liquidate user1's position by repaying MockYieldToken debt // User1 only has MOET debt, not MockYieldToken debt @@ -671,7 +605,6 @@ fun testManualLiquidation_supportedCollateralTypeNotInPosition() { ) // Should fail because user1's position doesn't have MockYieldToken debt Test.expect(liqRes, Test.beFailed()) - log(liqRes.error) Test.assertError(liqRes, errorMessage: "Cannot seize more collateral than is in position") } From dc775067bbdcbbe4adc787f07701d63d34629f72 Mon Sep 17 00:00:00 2001 From: Jordan Schalm Date: Tue, 20 Jan 2026 13:33:07 -0800 Subject: [PATCH 34/34] Update cadence/contracts/FlowCreditMarket.cdc --- cadence/contracts/FlowCreditMarket.cdc | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cadence/contracts/FlowCreditMarket.cdc b/cadence/contracts/FlowCreditMarket.cdc index 1f5d779..96b327a 100644 --- a/cadence/contracts/FlowCreditMarket.cdc +++ b/cadence/contracts/FlowCreditMarket.cdc @@ -1522,7 +1522,7 @@ access(all) contract FlowCreditMarket { let Ce_pre = balanceSheet.effectiveCollateral // effective collateral pre-liquidation let De_pre = balanceSheet.effectiveDebt // effective debt pre-liquidation let Fc = positionView.snapshots[seizeType]!.risk.collateralFactor - let Fd = positionView.snapshots[debtType]!.risk.collateralFactor + let Fd = positionView.snapshots[debtType]!.risk.borrowFactor let Ce_seize = UFix128(seizeAmount) * UFix128(Pc_oracle) * Fc // effective value of seized collateral ($) let De_seize = UFix128(repayAmount) * UFix128(Pd_oracle) * Fd // effective value of repaid debt ($)