diff --git a/cadence/contracts/FlowCreditMarket.cdc b/cadence/contracts/FlowCreditMarket.cdc index 80c5384..96b327a 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( @@ -314,15 +313,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 + /// 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 + /// 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 @@ -348,7 +347,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, @@ -356,34 +354,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 - } - } - - /// Liquidation quote output - access(all) struct LiquidationQuote { - access(all) let requiredRepay: UFix64 - access(all) let seizeType: Type - access(all) let seizeAmount: UFix64 - 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 } } @@ -624,13 +600,15 @@ access(all) contract FlowCreditMarket { /// to maintain precision when converting between scaled and true balances and when compounding. access(EImplementation) 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. access(EImplementation) 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. access(EImplementation) var currentDebitRate: UFix128 @@ -928,8 +906,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 @@ -943,6 +924,10 @@ access(all) contract FlowCreditMarket { borrowFactor: UFix128, liquidationBonus: UFix128 ) { + pre { + collateralFactor <= 1.0: "collateral factor must be <=1" + borrowFactor <= 1.0: "borrow factor must be <=1" + } self.collateralFactor = collateralFactor self.borrowFactor = borrowFactor self.liquidationBonus = liquidationBonus @@ -990,6 +975,26 @@ access(all) contract FlowCreditMarket { self.minHealth = min self.maxHealth = max } + + /// 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 { + 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 + } } // PURE HELPERS ------------------------------------------------------------- @@ -1003,6 +1008,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 var effectiveDebtTotal: UFix128 = 0.0 @@ -1047,6 +1053,7 @@ access(all) contract FlowCreditMarket { return 0.0 } + // TODO(jord): this logic duplicates BalanceSheet construction var effectiveCollateralTotal: UFix128 = 0.0 var effectiveDebtTotal: UFix128 = 0.0 @@ -1144,6 +1151,7 @@ access(all) contract FlowCreditMarket { 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 @@ -1157,13 +1165,25 @@ access(all) contract FlowCreditMarket { access(EImplementation) var version: UInt64 /// Liquidation target health and controls (global) - 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: 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) + // - 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 access(self) var allowedSwapperTypes: {Type: Bool} @@ -1171,9 +1191,11 @@ access(all) contract FlowCreditMarket { 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}) { @@ -1208,7 +1230,7 @@ access(all) contract FlowCreditMarket { self.lastUnpausedAt = nil self.protocolLiquidationFeeBps = 0 self.allowedSwapperTypes = {} - self.dexOracleDeviationBps = 300 // 3% default + self.dexOracleDeviationBps = UInt16(300) // 3% default self.dexMaxSlippageBps = 100 self.dexMaxRouteHops = 3 @@ -1252,7 +1274,6 @@ access(all) contract FlowCreditMarket { warmupSec: self.liquidationWarmupSec, lastUnpausedAt: self.lastUnpausedAt, triggerHF: 1.0, - protocolFeeBps: self.protocolLiquidationFeeBps ) } @@ -1282,6 +1303,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. @@ -1338,6 +1372,7 @@ access(all) contract FlowCreditMarket { /// 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) @@ -1436,306 +1471,126 @@ access(all) contract FlowCreditMarket { ) } - /// 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 { + /// 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 + /// + /// 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 + access(all) fun manualLiquidation( + pid: UInt64, + debtType: Type, + seizeType: Type, + seizeAmount: UFix64, + repayment: @{FungibleToken.Vault} + ): @{FungibleToken.Vault} { 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 - ) + 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 } - // Build snapshots - let debtState = self._borrowUpdatedTokenState(type: debtType) - let seizeState = self._borrowUpdatedTokenState(type: seizeType) + 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") + + // 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))") + 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) + // 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 + let De_pre = balanceSheet.effectiveDebt // effective debt pre-liquidation + let Fc = positionView.snapshots[seizeType]!.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 ($) + 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)") + + // 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 + // 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") + + // Compare the DEX price to the oracle price and revert if they diverge beyond configured threshold. + 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. + 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 - // 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) - ) - ) + assert(Pcd_dex_oracle_diffBps <= self.dexOracleDeviationBps, message: "Too large difference between dex/oracle prices diff=\(Pcd_dex_oracle_diffBps)bps") +*/ - // 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 - 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) - } - } + // Execute the liquidation + return <- self._doLiquidation(pid: pid, repayment: <-repayment, debtType: debtType, seizeType: seizeType, seizeAmount: seizeAmount) + } - // 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 - ) + /// 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 requiredEffColl = effDebt * target - if effColl >= requiredEffColl { - return FlowCreditMarket.LiquidationQuote( - requiredRepay: 0.0, - seizeType: seizeType, - seizeAmount: 0.0, - newHF: health - ) - } + let repayAmount = repayment.balance + assert(repayment.getType() == debtType, message: "Vault type mismatch for repay") + let debtReserveRef = self._borrowOrCreateReserveVault(type: debtType) + debtReserveRef.deposit(from: <-repayment) - 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 - let effDebtNew = effColl / target - if effDebt <= effDebtNew { - return FlowCreditMarket.LiquidationQuote( - requiredRepay: 0.0, - seizeType: seizeType, - seizeAmount: 0.0, - newHF: target - ) - } + // Reduce borrower's debt position by repayAmount + let position = self._borrowPosition(pid: pid) + let debtState = self._borrowUpdatedTokenState(type: debtType) - // 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 - ) + if position.balances[debtType] == nil { + position.balances[debtType] = InternalBalance(direction: BalanceDirection.Debit, scaledBalance: 0.0) } + position.balances[debtType]!.recordDeposit(amount: UFix128(repayAmount), tokenState: debtState) - // Derived formula with positive denominator: u = (t * effDebt - effColl) / (t - (1 + LB) * CF) - let num = effDebt * target - effColl - 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 - ) - } - 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 - } + // Withdraw seized collateral from position and send to liquidator + let seizeState = self._borrowUpdatedTokenState(type: seizeType) + if position.balances[seizeType] == nil { + position.balances[seizeType] = InternalBalance(direction: BalanceDirection.Credit, scaledBalance: 0.0) } - 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 - } + position.balances[seizeType]!.recordWithdrawal(amount: UFix128(seizeAmount), tokenState: seizeState) + let seizeReserveRef = (&self.reserves[seizeType] as auth(FungibleToken.Withdraw) &{FungibleToken.Vault}?)! + let seizedCollateral <- seizeReserveRef.withdraw(amount: seizeAmount) - 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 - ) - } + 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 - // No improving pair found - return FlowCreditMarket.LiquidationQuote( - requiredRepay: 0.0, - seizeType: seizeType, - seizeAmount: 0.0, - newHF: health - ) - } + emit LiquidationExecuted(pid: pid, poolUUID: self.uuid, debtType: debtType.identifier, repayAmount: repayAmount, seizeType: seizeType.identifier, seizeAmount: seizeAmount, newHF: newHealth) - 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 - ) + return <-seizedCollateral } /// Returns the quantity of funds of a specified token which would need to be deposited @@ -1775,309 +1630,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, @@ -3001,11 +2553,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, @@ -3018,15 +2568,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 ) } @@ -3408,6 +2954,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) @@ -3468,7 +3015,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} = {} @@ -4114,44 +3661,4 @@ access(all) contract FlowCreditMarket { ) let factory = self.account.storage.borrow<&PoolFactory>(from: self.PoolFactoryPath)! } - - 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) } diff --git a/cadence/contracts/mocks/MockDexSwapper.cdc b/cadence/contracts/mocks/MockDexSwapper.cdc index 7b88fa0..2ba8a17 100644 --- a/cadence/contracts/mocks/MockDexSwapper.cdc +++ b/cadence/contracts/mocks/MockDexSwapper.cdc @@ -87,5 +87,3 @@ access(all) contract MockDexSwapper { access(contract) fun setID(_ id: DeFiActions.UniqueIdentifier?) { self.uniqueID = id } } } - - 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) -} diff --git a/cadence/tests/liquidation_phase1_test.cdc b/cadence/tests/liquidation_phase1_test.cdc index 052dd4d..8577360 100644 --- a/cadence/tests/liquidation_phase1_test.cdc +++ b/cadence/tests/liquidation_phase1_test.cdc @@ -3,10 +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) @@ -38,8 +41,9 @@ 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 @@ -49,325 +53,578 @@ fun test_liquidation_phase1_quote_and_execute() { transferFlowTokens(to: user, amount: 1000.0) // open wrapped position and deposit via existing helper txs - let openRes = _executeTransaction( - "./transactions/mock-flow-credit-market-consumer/create_wrapped_position.cdc", - [1000.0, /storage/flowTokenVault, true], - user + // debt is MOET, collateral is FLOW + createWrappedPosition(signer: user, amount: 1000.0, vaultStoragePath: /storage/flowTokenVault, pushToDrawDownSink: true) + + // Log initial health + 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) + mintMoet(signer: Test.getAccount(0x0000000000000007), to: liquidator.address, amount: 1000.0, beFailed: false) + + let liqBalance = getBalance(address: liquidator.address, vaultPublicPath: MOET.VaultPublicPath) ?? 0.0 + + // 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(openRes, Test.beSucceeded()) + Test.expect(liqRes, Test.beFailed()) + Test.assertError(liqRes, errorMessage: "Cannot liquidate healthy position") +} + +/// Should be unable to liquidate a position to above target health. +access(all) +fun testManualLiquidation_liquidationExceedsTargetHealth() { + 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 + 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 - 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)") - - // 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)) + mintMoet(signer: Test.getAccount(0x0000000000000007), to: liquidator.address, amount: 1000.0, beFailed: false) - let liqBalance = getBalance(address: liquidator.address, vaultPublicPath: /public/moetBalance) ?? 0.0 - log("Liquidator MOET balance after mint: \(liqBalance)") + let liqBalance = getBalance(address: liquidator.address, vaultPublicPath: MOET.VaultPublicPath) ?? 0.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/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, seizeAmount, repayAmount], liquidator ) - Test.expect(liqRes, Test.beSucceeded()) + // 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) - let hAfterLiqUF = FlowCreditMarketMath.toUFix64Round(hAfterLiq) - log("[LIQ] Health after liquidation: raw=\(hAfterLiq), approx=\(hAfterLiqUF)") - // 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") - - // Assert quoted newHF matches actual - 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") + Test.assert(hAfterLiq == hAfterPrice, message: "sanity check: health should not change after failed liquidation") } -// DEX liquidation tests moved to liquidation_phase2_dex_test.cdc - +/// Should be unable to liquidate a position by repaying more debt than the position holds. access(all) -fun test_liquidation_insolvency() { +fun testManualLiquidation_repayExceedsDebt() { safeReset() let pid: UInt64 = 0 + // user setup 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()) + // open wrapped position and deposit via existing helper txs + // debt is MOET, collateral is FLOW + createWrappedPosition(signer: user, amount: 1000.0, vaultStoragePath: /storage/flowTokenVault, pushToDrawDownSink: true) - // 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) + // health before price drop + let hBefore = getPositionHealth(pid: pid, beFailed: false) - // 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) + // cause undercollateralization + let newPrice = 0.7 // $/FLOW + setMockOraclePrice(signer: Test.getAccount(0x0000000000000007), forTokenIdentifier: flowTokenIdentifier, price: newPrice) + let hAfterPrice = getPositionHealth(pid: pid, beFailed: false) + + let debtPositionBalance = getPositionBalance(pid: pid, vaultID: moetIdentifier) + Test.assert(debtPositionBalance.direction == FlowCreditMarket.BalanceDirection.Debit) + var debtBalance = debtPositionBalance.balance + + // execute liquidation + let liquidator = Test.createAccount() + setupMoetVault(liquidator, beFailed: false) + mintMoet(signer: Test.getAccount(0x0000000000000007), to: liquidator.address, amount: 1000.0, beFailed: false) - // 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 liqBalance = getBalance(address: liquidator.address, vaultPublicPath: MOET.VaultPublicPath) ?? 0.0 + // 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/liquidate_repay_for_seize.cdc", - [pid, Type<@MOET.Vault>().identifier, flowTokenIdentifier, quote.requiredRepay + 0.00000001, 0.0], - keeper + "../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 too much + Test.expect(liqRes, Test.beFailed()) + Test.assertError(liqRes, errorMessage: "Cannot repay more debt than is in position") - // 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") + // health after liquidation + let hAfterLiq = getPositionHealth(pid: pid, beFailed: false) + + Test.assert(hAfterLiq == hAfterPrice, message: "sanity check: health should not change after failed liquidation") } +/// Should be unable to liquidate a position by seizing more collateral than the position holds. access(all) -fun test_multi_liquidation() { +fun testManualLiquidation_seizeExceedsCollateral() { safeReset() 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 - ) - - // 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 + // open wrapped position and deposit via existing helper txs + // debt is MOET, collateral is FLOW + createWrappedPosition(signer: user, amount: 1000.0, vaultStoragePath: /storage/flowTokenVault, pushToDrawDownSink: true) - let keeper1 = Test.createAccount() - setupMoetVault(keeper1, beFailed: false) - _executeTransaction("../transactions/moet/mint_moet.cdc", [keeper1.address, quote1.requiredRepay], Test.getAccount(0x0000000000000007)) + // health before price drop + let hBefore = getPositionHealth(pid: pid, beFailed: false) - _executeTransaction( - "../transactions/flow-credit-market/pool-management/liquidate_repay_for_seize.cdc", - [pid, Type<@MOET.Vault>().identifier, flowTokenIdentifier, quote1.requiredRepay, 0.0], - keeper1 - ) + // 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 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") + let collateralBalance = getPositionBalance(pid: pid, vaultID: flowTokenIdentifier).balance - // Drop price further for second liquidation - setMockOraclePrice(signer: Test.getAccount(0x0000000000000007), forTokenIdentifier: flowTokenIdentifier, price: 0.6) + // execute liquidation + let liquidator = Test.createAccount() + setupMoetVault(liquidator, beFailed: false) + mintMoet(signer: Test.getAccount(0x0000000000000007), to: liquidator.address, amount: 1000.0, beFailed: false) - let hAfterDrop = getPositionHealth(pid: pid, beFailed: false) - Test.assert(FlowCreditMarketMath.toUFix64Round(hAfterDrop) < 1.0) + let liqBalance = getBalance(address: liquidator.address, vaultPublicPath: MOET.VaultPublicPath) ?? 0.0 - // Second liquidation - let quote2Res = _executeScript( - "../scripts/flow-credit-market/quote_liquidation.cdc", - [pid, Type<@MOET.Vault>().identifier, flowTokenIdentifier] + // 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 ) - let quote2 = quote2Res.returnValue as! FlowCreditMarket.LiquidationQuote + // 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") - 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 - ) + // health after liquidation + let hAfterLiq = getPositionHealth(pid: pid, beFailed: false) - 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") + 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 test_liquidation_overpay_attempt() { +fun testManualLiquidation_reduceHealth() { safeReset() let pid: UInt64 = 0 + // user setup 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()) + // open wrapped position and deposit via existing helper txs + // debt is MOET, collateral is FLOW + createWrappedPosition(signer: user, amount: 1000.0, vaultStoragePath: /storage/flowTokenVault, pushToDrawDownSink: true) - setMockOraclePrice(signer: Test.getAccount(0x0000000000000007), forTokenIdentifier: flowTokenIdentifier, price: 0.7) + // health before price drop + let hBefore = getPositionHealth(pid: pid, beFailed: false) - 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 - } + // 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 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 overpayAmount = quote.requiredRepay + 10.0 - _executeTransaction("../transactions/moet/mint_moet.cdc", [liquidator.address, overpayAmount], Test.getAccount(0x0000000000000007)) + mintMoet(signer: Test.getAccount(0x0000000000000007), to: liquidator.address, amount: 1000.0, beFailed: false) - let balanceBefore = getBalance(address: liquidator.address, vaultPublicPath: MOET.ReceiverPublicPath) ?? 0.0 - let collBalanceBefore = getBalance(address: liquidator.address, vaultPublicPath: /public/flowTokenReceiver) ?? 0.0 + let liqBalance = getBalance(address: liquidator.address, vaultPublicPath: MOET.VaultPublicPath) ?? 0.0 + // 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/liquidate_repay_for_seize.cdc", - [pid, Type<@MOET.Vault>().identifier, flowTokenIdentifier, overpayAmount, 0.0], + "../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()) - let balanceAfter = getBalance(address: liquidator.address, vaultPublicPath: MOET.ReceiverPublicPath) ?? 0.0 - let collBalanceAfter = getBalance(address: liquidator.address, vaultPublicPath: /public/flowTokenReceiver) ?? 0.0 + // 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") - Test.assert(balanceAfter == balanceBefore - quote.requiredRepay, message: "Actual repay not equal to requiredRepay") - Test.assert(collBalanceAfter == collBalanceBefore + quote.seizeAmount, message: "Seize amount changed") + 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) + Test.assert(hAfterLiq < hAfterPrice, message: "test expects health to decrease after liquidation") } +/// Test the case where the liquidator provides a repayment vault of the collateral type instead of debt type. access(all) -fun test_liquidation_slippage_failure() { +fun testManualLiquidation_repaymentVaultCollateralType() { safeReset() let pid: UInt64 = 0 - // Setup similar to first test + // 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 - ) + // open wrapped position and deposit via existing helper txs + // debt is MOET, collateral is FLOW + createWrappedPosition(signer: user, amount: 1000.0, vaultStoragePath: /storage/flowTokenVault, pushToDrawDownSink: true) - setMockOraclePrice(signer: Test.getAccount(0x0000000000000007), forTokenIdentifier: flowTokenIdentifier, price: 0.7) + // health before price drop + let hBefore = getPositionHealth(pid: pid, beFailed: false) - let quoteRes = _executeScript( - "../scripts/flow-credit-market/quote_liquidation.cdc", - [pid, Type<@MOET.Vault>().identifier, flowTokenIdentifier] - ) - let quote = quoteRes.returnValue as! FlowCreditMarket.LiquidationQuote + // cause undercollateralization + let newPrice = 0.7 // $/FLOW + setMockOraclePrice(signer: Test.getAccount(0x0000000000000007), forTokenIdentifier: flowTokenIdentifier, price: newPrice) + let hAfterPrice = getPositionHealth(pid: pid, beFailed: false) + + 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() - setupMoetVault(liquidator, beFailed: false) - _executeTransaction("../transactions/moet/mint_moet.cdc", [liquidator.address, quote.requiredRepay], Test.getAccount(0x0000000000000007)) + transferFlowTokens(to: liquidator, amount: 1000.0) - // 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], + // 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 ) - Test.expect(lowMaxRes, Test.beFailed()) + // 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 with different type than the debt type. +access(all) +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 + createWrappedPosition(signer: user, amount: 1000.0, vaultStoragePath: /storage/flowTokenVault, pushToDrawDownSink: true) + + // health before price drop + let hBefore = getPositionHealth(pid: pid, beFailed: false) + + // cause undercollateralization + let newPrice = 0.7 // $/FLOW + setMockOraclePrice(signer: Test.getAccount(0x0000000000000007), forTokenIdentifier: flowTokenIdentifier, price: newPrice) + let hAfterPrice = getPositionHealth(pid: pid, beFailed: false) - // 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], + 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 + + // 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 ) - Test.expect(highMinRes, Test.beFailed()) + // 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 a liquidator provides repayment in an unsupported debt type. +access(all) +fun testManualLiquidation_unsupportedDebtType() { + 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 + createWrappedPosition(signer: user, amount: 1000.0, vaultStoragePath: /storage/flowTokenVault, pushToDrawDownSink: true) + + // health before price drop + let hBefore = getPositionHealth(pid: pid, beFailed: false) + + // cause undercollateralization + let newPrice = 0.7 // $/FLOW + setMockOraclePrice(signer: Test.getAccount(0x0000000000000007), forTokenIdentifier: flowTokenIdentifier, price: newPrice) + let hAfterPrice = getPositionHealth(pid: pid, beFailed: false) + + 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 + + // 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 test_liquidation_healthy_zero_quote() { +fun testManualLiquidation_unsupportedCollateralType() { safeReset() let pid: UInt64 = 0 + // user setup 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 + // open wrapped position and deposit via existing helper txs + // debt is MOET, collateral is FLOW + createWrappedPosition(signer: user, amount: 1000.0, vaultStoragePath: /storage/flowTokenVault, pushToDrawDownSink: true) + + // health before price drop + let hBefore = getPositionHealth(pid: pid, beFailed: false) + + // cause undercollateralization + let newPrice = 0.7 // $/FLOW + setMockOraclePrice(signer: Test.getAccount(0x0000000000000007), forTokenIdentifier: flowTokenIdentifier, price: newPrice) + let hAfterPrice = getPositionHealth(pid: pid, beFailed: false) + + 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) + setupMockYieldTokenVault(liquidator, beFailed: false) + mintMoet(signer: Test.getAccount(0x0000000000000007), to: liquidator.address, amount: 1000.0, beFailed: false) + + let liqBalance = getBalance(address: liquidator.address, vaultPublicPath: MOET.VaultPublicPath) ?? 0.0 + + // 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 ) - Test.expect(openRes, Test.beSucceeded()) + // Should fail because we are specifying an unsupported collateral type (yield token) + Test.expect(liqRes, Test.beFailed()) + Test.assertError(liqRes, errorMessage: "Collateral token type unsupported") +} - // Set price to make HF > 1.0 (healthy) - setMockOraclePrice(signer: Test.getAccount(0x0000000000000007), forTokenIdentifier: flowTokenIdentifier, price: 1.2) +/// 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) - let h = getPositionHealth(pid: pid, beFailed: false) - let hUF = FlowCreditMarketMath.toUFix64Round(h) - Test.assert(hUF > 1.0, message: "Position not healthy") + // 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) + + // 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) + + // 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 - let quoteRes = _executeScript( - "../scripts/flow-credit-market/quote_liquidation.cdc", - [pid, Type<@MOET.Vault>().identifier, flowTokenIdentifier] + // 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 ) - Test.expect(quoteRes, Test.beSucceeded()) - let quote = quoteRes.returnValue as! FlowCreditMarket.LiquidationQuote + // 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_supportedCollateralTypeNotInPosition() { + safeReset() + let protocolAccount = Test.getAccount(0x0000000000000007) - 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") + // 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) + + // 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) + + // 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 + + // 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()) + Test.assertError(liqRes, errorMessage: "Cannot seize more collateral than is in position") } -// Time-based warmup enforcement test removed +/// 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_increaseHealthBelowTarget() {} + +/// Should be able to liquidate to exactly target health +access(all) +fun testManualLiquidation_liquidateToTarget() {} + diff --git a/cadence/tests/liquidation_phase2_dex_test.cdc b/cadence/tests/liquidation_phase2_dex_test.cdc deleted file mode 100644 index 462c689..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) -} - - diff --git a/cadence/tests/test_helpers.cdc b/cadence/tests/test_helpers.cdc index 04ec197..d1ea14f 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]) @@ -429,6 +440,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/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..f0efd0a --- /dev/null +++ b/cadence/transactions/flow-credit-market/pool-management/manual_liquidation.cdc @@ -0,0 +1,62 @@ +import "FungibleToken" +import "FungibleTokenMetadataViews" +import "MetadataViews" + +import "FlowCreditMarket" + +/// 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 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.debtType, + seizeType: self.seizeType, + seizeAmount: seizeAmount, + repayment: <-self.repay + ) + + self.receiver.deposit(from: <-seizedVault) + } +} 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") + } + } +}