diff --git a/FlowActions b/FlowActions index 1254f6e..d014ce4 160000 --- a/FlowActions +++ b/FlowActions @@ -1 +1 @@ -Subproject commit 1254f6e94fe23e27490d9df042de186b29e5e4cc +Subproject commit d014ce49a4ee3cf663c03c6a9b4498a7a1bbff58 diff --git a/cadence/contracts/FlowCreditMarket.cdc b/cadence/contracts/FlowCreditMarket.cdc index 96b327a..d9ae313 100644 --- a/cadence/contracts/FlowCreditMarket.cdc +++ b/cadence/contracts/FlowCreditMarket.cdc @@ -1185,20 +1185,31 @@ access(all) contract FlowCreditMarket { // - also to make allowlist pattern work with automated liquidation, initiator of this automation will need actual handle on a dex in order to pass it to FCM /// Allowlist of permitted DeFiActions Swapper types for DEX liquidations + /// TODO: unused! To remove, must re-deploy existing contracts access(self) var allowedSwapperTypes: {Type: Bool} + /// A trusted DEX (or set of DEXes) used by FCM as a pricing oracle and trading counterparty for liquidations. + /// The SwapperProvider implementation MUST return a Swapper for all possible (ordered) pairs of supported tokens. + /// If [X1, X2, ..., Xn] is the set of supported tokens, then the SwapperProvider must return a Swapper for all pairs: + /// (Xi, Xj) where i∈[1,n], j∈[1,n], i≠j + /// + /// FCM does not attempt to construct multi-part paths (using multiple Swappers) or compare prices across Swappers. + /// It relies directly on the Swapper's returned by the configured SwapperProvider. + access(self) let dex: {DeFiActions.SwapperProvider} + /// Max allowed deviation in basis points between DEX-implied price and oracle price access(self) var dexOracleDeviationBps: UInt16 /// Max slippage allowed in basis points for DEX liquidations /// TODO(jord): revisit this. Is this ever necessary if we are also checking dexOracleDeviationBps? Do we want both a spot price check and a slippage from spot price check? + /// TODO: unused! To remove, must re-deploy existing contracts access(self) var dexMaxSlippageBps: UInt64 /// Max route hops allowed for DEX liquidations - // TODO(jord): unused + /// TODO: unused! To remove, must re-deploy existing contracts access(self) var dexMaxRouteHops: UInt64 - init(defaultToken: Type, priceOracle: {DeFiActions.PriceOracle}) { + init(defaultToken: Type, priceOracle: {DeFiActions.PriceOracle}, dex: {DeFiActions.SwapperProvider}) { pre { priceOracle.unitOfAccount() == defaultToken: "Price oracle must return prices in terms of the default token" @@ -1230,6 +1241,7 @@ access(all) contract FlowCreditMarket { self.lastUnpausedAt = nil self.protocolLiquidationFeeBps = 0 self.allowedSwapperTypes = {} + self.dex = dex self.dexOracleDeviationBps = UInt16(300) // 3% default self.dexMaxSlippageBps = 100 self.dexMaxRouteHops = 3 @@ -1531,14 +1543,12 @@ access(all) contract FlowCreditMarket { let postHealth = FlowCreditMarket.healthComputation(effectiveCollateral: Ce_post, effectiveDebt: De_post) assert(postHealth <= self.liquidationTargetHF, message: "Liquidation must not exceed target health: \(postHealth)>\(self.liquidationTargetHF)") - // TODO(jord): uncomment following when implementing 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 + 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 @@ -1548,7 +1558,6 @@ access(all) contract FlowCreditMarket { let Pcd_dex_oracle_diffBps = UInt16(Pcd_dex_oracle_diffPct * 10_000.0) // cannot overflow because Pcd_dex_oracle_diffPct<=1 assert(Pcd_dex_oracle_diffBps <= self.dexOracleDeviationBps, message: "Too large difference between dex/oracle prices diff=\(Pcd_dex_oracle_diffBps)bps") -*/ // Execute the liquidation return <- self._doLiquidation(pid: pid, repayment: <-repayment, debtType: debtType, seizeType: seizeType, seizeAmount: seizeAmount) @@ -2100,7 +2109,7 @@ access(all) contract FlowCreditMarket { ) } - // Returns health value of this position if the given amount of the specified token were withdrawn without + // Returns health value of this position if the given amount of the specified token were withdrawn without // using the top up source. // NOTE: This method can return health values below 1.0, which aren't actually allowed. This indicates // that the proposed withdrawal would fail (unless a top up source is available and used). @@ -3082,12 +3091,12 @@ access(all) contract FlowCreditMarket { /// access(all) resource PoolFactory { /// Creates the contract-managed Pool and saves it to the canonical path, reverting if one is already stored - access(all) fun createPool(defaultToken: Type, priceOracle: {DeFiActions.PriceOracle}) { + access(all) fun createPool(defaultToken: Type, priceOracle: {DeFiActions.PriceOracle}, dex: {DeFiActions.SwapperProvider}) { pre { FlowCreditMarket.account.storage.type(at: FlowCreditMarket.PoolStoragePath) == nil: "Storage collision - Pool has already been created & saved to \(FlowCreditMarket.PoolStoragePath)" } - let pool <- create Pool(defaultToken: defaultToken, priceOracle: priceOracle) + let pool <- create Pool(defaultToken: defaultToken, priceOracle: priceOracle, dex: dex) FlowCreditMarket.account.storage.save(<-pool, to: FlowCreditMarket.PoolStoragePath) let cap = FlowCreditMarket.account.capabilities.storage.issue<&Pool>(FlowCreditMarket.PoolStoragePath) FlowCreditMarket.account.capabilities.unpublish(FlowCreditMarket.PoolPublicPath) diff --git a/cadence/contracts/mocks/MockDexSwapper.cdc b/cadence/contracts/mocks/MockDexSwapper.cdc index 2ba8a17..a140951 100644 --- a/cadence/contracts/mocks/MockDexSwapper.cdc +++ b/cadence/contracts/mocks/MockDexSwapper.cdc @@ -8,6 +8,33 @@ import "DeFiActionsUtils" /// Do NOT use in production. access(all) contract MockDexSwapper { + /// Holds the set of available swappers which will be provided to users of SwapperProvider. + /// inType -> outType -> Swapper + access(self) let swappers: {Type: {Type: Swapper}} + + init() { + self.swappers = {} + } + + access(all) fun getSwapper(inType: Type, outType: Type): Swapper? { + if let swappersForInType = self.swappers[inType] { + return swappersForInType[outType] + } + return nil + } + + /// Used by testing code to configure the DEX with swappers. + /// Overwrites existing swapper with same types, if any. + access(all) fun _addSwapper(swapper: Swapper) { + if self.swappers[swapper.inType()] == nil { + self.swappers[swapper.inType()] = { swapper.outType(): swapper } + } else { + let swappersForInType = self.swappers[swapper.inType()]! + swappersForInType[swapper.outType()] = swapper + self.swappers[swapper.inType()] = swappersForInType + } + } + access(all) struct BasicQuote : DeFiActions.Quote { access(all) let inType: Type access(all) let outType: Type @@ -21,9 +48,11 @@ access(all) contract MockDexSwapper { } } + /// NOTE: reverse swaps are unsupported. access(all) struct Swapper : DeFiActions.Swapper { access(self) let inVault: Type access(self) let outVault: Type + /// source for output tokens only (reverse swaps unsupported) access(self) let vaultSource: Capability access(self) let priceRatio: UFix64 // out per unit in access(contract) var uniqueID: DeFiActions.UniqueIdentifier? @@ -86,4 +115,10 @@ access(all) contract MockDexSwapper { access(contract) view fun copyID(): DeFiActions.UniqueIdentifier? { return self.uniqueID } access(contract) fun setID(_ id: DeFiActions.UniqueIdentifier?) { self.uniqueID = id } } + + access(all) struct SwapperProvider : DeFiActions.SwapperProvider { + access(all) fun getSwapper(inType: Type, outType: Type): Swapper? { + return MockDexSwapper.getSwapper(inType: inType, outType: outType) + } + } } diff --git a/cadence/tests/liquidation_phase1_test.cdc b/cadence/tests/liquidation_phase1_test.cdc index 8577360..06b0d32 100644 --- a/cadence/tests/liquidation_phase1_test.cdc +++ b/cadence/tests/liquidation_phase1_test.cdc @@ -27,6 +27,7 @@ fun setup() { let protocolAccount = Test.getAccount(0x0000000000000007) setMockOraclePrice(signer: protocolAccount, forTokenIdentifier: flowTokenIdentifier, price: 1.0) + // setMockOraclePrice(signer: protocolAccount, forTokenIdentifier: moetIdentifier, price: 1.0) createAndStorePool(signer: protocolAccount, defaultTokenIdentifier: defaultTokenIdentifier, beFailed: false) grantPoolCapToConsumer() addSupportedTokenZeroRateCurve( @@ -38,6 +39,16 @@ fun setup() { depositCapacityCap: 1_000_000.0 ) + // Add DEX swapper for FLOW -> MOET pair (for liquidations) + // priceRatio = 1.0 means 1 MOET per 1 FLOW (matches oracle prices) + addMockDexSwapper( + signer: protocolAccount, + inVaultIdentifier: flowTokenIdentifier, + outVaultIdentifier: moetIdentifier, + vaultSourceStoragePath: /storage/moetTokenVault_0x0000000000000007, + priceRatio: 1.0 + ) + snapshot = getCurrentBlockHeight() } @@ -100,6 +111,14 @@ fun testManualLiquidation_liquidationExceedsTargetHealth() { // cause undercollateralization let newPrice = 0.7 // $/FLOW setMockOraclePrice(signer: Test.getAccount(0x0000000000000007), forTokenIdentifier: flowTokenIdentifier, price: newPrice) + // Update DEX price to match oracle + addMockDexSwapper( + signer: Test.getAccount(0x0000000000000007), + inVaultIdentifier: flowTokenIdentifier, + outVaultIdentifier: moetIdentifier, + vaultSourceStoragePath: /storage/moetTokenVault_0x0000000000000007, + priceRatio: newPrice + ) let hAfterPrice = getPositionHealth(pid: pid, beFailed: false) // execute liquidation @@ -149,6 +168,14 @@ fun testManualLiquidation_repayExceedsDebt() { // cause undercollateralization let newPrice = 0.7 // $/FLOW setMockOraclePrice(signer: Test.getAccount(0x0000000000000007), forTokenIdentifier: flowTokenIdentifier, price: newPrice) + // Update DEX price to match oracle + addMockDexSwapper( + signer: Test.getAccount(0x0000000000000007), + inVaultIdentifier: flowTokenIdentifier, + outVaultIdentifier: moetIdentifier, + vaultSourceStoragePath: /storage/moetTokenVault_0x0000000000000007, + priceRatio: newPrice + ) let hAfterPrice = getPositionHealth(pid: pid, beFailed: false) let debtPositionBalance = getPositionBalance(pid: pid, vaultID: moetIdentifier) @@ -201,6 +228,14 @@ fun testManualLiquidation_seizeExceedsCollateral() { // cause undercollateralization AND insolvency let newPrice = 0.5 // $/FLOW setMockOraclePrice(signer: Test.getAccount(0x0000000000000007), forTokenIdentifier: flowTokenIdentifier, price: newPrice) + // Update DEX price to match oracle + addMockDexSwapper( + signer: Test.getAccount(0x0000000000000007), + inVaultIdentifier: flowTokenIdentifier, + outVaultIdentifier: moetIdentifier, + vaultSourceStoragePath: /storage/moetTokenVault_0x0000000000000007, + priceRatio: newPrice + ) let hAfterPrice = getPositionHealth(pid: pid, beFailed: false) let collateralBalance = getPositionBalance(pid: pid, vaultID: flowTokenIdentifier).balance @@ -251,6 +286,14 @@ fun testManualLiquidation_reduceHealth() { // cause undercollateralization AND insolvency let newPrice = 0.5 // $/FLOW setMockOraclePrice(signer: Test.getAccount(0x0000000000000007), forTokenIdentifier: flowTokenIdentifier, price: newPrice) + // Update DEX price to match oracle + addMockDexSwapper( + signer: Test.getAccount(0x0000000000000007), + inVaultIdentifier: flowTokenIdentifier, + outVaultIdentifier: moetIdentifier, + vaultSourceStoragePath: /storage/moetTokenVault_0x0000000000000007, + priceRatio: newPrice + ) let hAfterPrice = getPositionHealth(pid: pid, beFailed: false) let collateralBalancePreLiq = getPositionBalance(pid: pid, vaultID: flowTokenIdentifier).balance @@ -288,6 +331,127 @@ fun testManualLiquidation_reduceHealth() { Test.assert(hAfterLiq < hAfterPrice, message: "test expects health to decrease after liquidation") } +/// Should be able to liquidate to below target health while increasing health factor. +access(all) +fun testManualLiquidation_increaseHealthBelowTarget() { + 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) + + // cause severe undercollateralization + let newPrice = 0.5 // $/FLOW + setMockOraclePrice(signer: Test.getAccount(0x0000000000000007), forTokenIdentifier: flowTokenIdentifier, price: newPrice) + + // Update DEX price to match oracle + addMockDexSwapper( + signer: Test.getAccount(0x0000000000000007), + inVaultIdentifier: flowTokenIdentifier, + outVaultIdentifier: moetIdentifier, + vaultSourceStoragePath: /storage/moetTokenVault_0x0000000000000007, + priceRatio: newPrice + ) + + let healthBefore = getPositionHealth(pid: pid, beFailed: false) + Test.assert(healthBefore < 1.05, message: "position should be unhealthy before liquidation") + + // execute liquidation + let liquidator = Test.createAccount() + setupMoetVault(liquidator, beFailed: false) + mintMoet(signer: Test.getAccount(0x0000000000000007), to: liquidator.address, amount: 1000.0, beFailed: false) + + // Repay MOET to seize FLOW + // DEX quote would require: 100/0.5 = 200 FLOW + // Liquidator offers 150 FLOW < 200 FLOW (better price) + let repayAmount = 100.0 + let seizeAmount = 150.0 + let liqRes = _executeTransaction( + "../transactions/flow-credit-market/pool-management/manual_liquidation.cdc", + [pid, Type<@MOET.Vault>().identifier, flowTokenIdentifier, seizeAmount, repayAmount], + liquidator + ) + // Should succeed + Test.expect(liqRes, Test.beSucceeded()) + + // Check post-liquidation health + let healthAfter = getPositionHealth(pid: pid, beFailed: false) + + // Health should have improved + Test.assert(healthAfter > healthBefore, message: "health should improve after liquidation") + + // Health should still be below target + Test.assert(healthAfter < 1.05, message: "health should still be below target (1.05)") +} + +/// Should be able to liquidate to exactly target health +access(all) +fun testManualLiquidation_liquidateToTarget() { + 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) + + // cause undercollateralization + let newPrice = 0.7 // $/FLOW + setMockOraclePrice(signer: Test.getAccount(0x0000000000000007), forTokenIdentifier: flowTokenIdentifier, price: newPrice) + + // Update DEX price to match oracle + addMockDexSwapper( + signer: Test.getAccount(0x0000000000000007), + inVaultIdentifier: flowTokenIdentifier, + outVaultIdentifier: moetIdentifier, + vaultSourceStoragePath: /storage/moetTokenVault_0x0000000000000007, + priceRatio: newPrice + ) + + let healthBefore = getPositionHealth(pid: pid, beFailed: false) + Test.assert(healthBefore < 1.05, message: "position should be unhealthy before liquidation") + + // execute liquidation + let liquidator = Test.createAccount() + setupMoetVault(liquidator, beFailed: false) + mintMoet(signer: Test.getAccount(0x0000000000000007), to: liquidator.address, amount: 1000.0, beFailed: false) + + // Repay MOET to seize FLOW - calculated to bring health to exactly 1.05 + // Initial: 1000 FLOW at $0.7 with effective collateral factor 0.8 + // Debt: ~615.38 MOET (from auto-borrow at creation) + // Pre-health: (1000 * 0.7 * 0.8) / 615.38 = 0.91 + // Target post-health: 1.05 + // Formula: (1000 - seizeAmount) * 0.7 * 0.8 / (615.38 - repayAmount) = 1.05 + // Using repayAmount = 100: seizeAmount = 33.66 + // DEX quote would require: 100/0.7 = 142.86 FLOW + // Liquidator offers 33.66 FLOW < 142.86 FLOW (better price) + let repayAmount = 100.0 + let seizeAmount = 33.66 + let liqRes = _executeTransaction( + "../transactions/flow-credit-market/pool-management/manual_liquidation.cdc", + [pid, Type<@MOET.Vault>().identifier, flowTokenIdentifier, seizeAmount, repayAmount], + liquidator + ) + // Should succeed + Test.expect(liqRes, Test.beSucceeded()) + + // Check post-liquidation health + let healthAfter = getPositionHealth(pid: pid, beFailed: false) + + // Health should be very close to target (1.05), allowing for small variance + Test.assert(healthAfter >= 1.04 && healthAfter <= 1.06, message: "health should be close to target (1.05), actual: ".concat(healthAfter.toString())) +} + /// Test the case where the liquidator provides a repayment vault of the collateral type instead of debt type. access(all) fun testManualLiquidation_repaymentVaultCollateralType() { @@ -309,6 +473,14 @@ fun testManualLiquidation_repaymentVaultCollateralType() { // cause undercollateralization let newPrice = 0.7 // $/FLOW setMockOraclePrice(signer: Test.getAccount(0x0000000000000007), forTokenIdentifier: flowTokenIdentifier, price: newPrice) + // Update DEX price to match oracle + addMockDexSwapper( + signer: Test.getAccount(0x0000000000000007), + inVaultIdentifier: flowTokenIdentifier, + outVaultIdentifier: moetIdentifier, + vaultSourceStoragePath: /storage/moetTokenVault_0x0000000000000007, + priceRatio: newPrice + ) let hAfterPrice = getPositionHealth(pid: pid, beFailed: false) let debtPositionBalance = getPositionBalance(pid: pid, vaultID: moetIdentifier) @@ -354,6 +526,14 @@ fun testManualLiquidation_repaymentVaultTypeMismatch() { // cause undercollateralization let newPrice = 0.7 // $/FLOW setMockOraclePrice(signer: Test.getAccount(0x0000000000000007), forTokenIdentifier: flowTokenIdentifier, price: newPrice) + // Update DEX price to match oracle + addMockDexSwapper( + signer: Test.getAccount(0x0000000000000007), + inVaultIdentifier: flowTokenIdentifier, + outVaultIdentifier: moetIdentifier, + vaultSourceStoragePath: /storage/moetTokenVault_0x0000000000000007, + priceRatio: newPrice + ) let hAfterPrice = getPositionHealth(pid: pid, beFailed: false) let debtPositionBalance = getPositionBalance(pid: pid, vaultID: moetIdentifier) @@ -401,6 +581,14 @@ fun testManualLiquidation_unsupportedDebtType() { // cause undercollateralization let newPrice = 0.7 // $/FLOW setMockOraclePrice(signer: Test.getAccount(0x0000000000000007), forTokenIdentifier: flowTokenIdentifier, price: newPrice) + // Update DEX price to match oracle + addMockDexSwapper( + signer: Test.getAccount(0x0000000000000007), + inVaultIdentifier: flowTokenIdentifier, + outVaultIdentifier: moetIdentifier, + vaultSourceStoragePath: /storage/moetTokenVault_0x0000000000000007, + priceRatio: newPrice + ) let hAfterPrice = getPositionHealth(pid: pid, beFailed: false) let debtPositionBalance = getPositionBalance(pid: pid, vaultID: moetIdentifier) @@ -448,6 +636,14 @@ fun testManualLiquidation_unsupportedCollateralType() { // cause undercollateralization let newPrice = 0.7 // $/FLOW setMockOraclePrice(signer: Test.getAccount(0x0000000000000007), forTokenIdentifier: flowTokenIdentifier, price: newPrice) + // Update DEX price to match oracle + addMockDexSwapper( + signer: Test.getAccount(0x0000000000000007), + inVaultIdentifier: flowTokenIdentifier, + outVaultIdentifier: moetIdentifier, + vaultSourceStoragePath: /storage/moetTokenVault_0x0000000000000007, + priceRatio: newPrice + ) let hAfterPrice = getPositionHealth(pid: pid, beFailed: false) let collateralBalancePreLiq = getPositionBalance(pid: pid, vaultID: flowTokenIdentifier).balance @@ -492,6 +688,15 @@ fun testManualLiquidation_supportedDebtTypeNotInPosition() { depositCapacityCap: 1_000_000.0 ) + // Add DEX swapper for MockYieldToken -> MOET pair + addMockDexSwapper( + signer: protocolAccount, + inVaultIdentifier: mockYieldTokenIdentifier, + outVaultIdentifier: moetIdentifier, + vaultSourceStoragePath: /storage/moetTokenVault_0x0000000000000007, + priceRatio: 1.0 + ) + // user1 setup - deposits FLOW let user1 = Test.createAccount() setupMoetVault(user1, beFailed: false) @@ -518,6 +723,14 @@ fun testManualLiquidation_supportedDebtTypeNotInPosition() { // cause undercollateralization for user1 by dropping FLOW price let newPrice = 0.7 // $/FLOW setMockOraclePrice(signer: protocolAccount, forTokenIdentifier: flowTokenIdentifier, price: newPrice) + // Update DEX price to match oracle + addMockDexSwapper( + signer: protocolAccount, + inVaultIdentifier: flowTokenIdentifier, + outVaultIdentifier: moetIdentifier, + vaultSourceStoragePath: /storage/moetTokenVault_0x0000000000000007, + priceRatio: newPrice + ) let hAfterPrice = getPositionHealth(pid: pid1, beFailed: false) // execute liquidation @@ -559,6 +772,15 @@ fun testManualLiquidation_supportedCollateralTypeNotInPosition() { depositCapacityCap: 1_000_000.0 ) + // Add DEX swapper for MockYieldToken -> MOET pair + addMockDexSwapper( + signer: protocolAccount, + inVaultIdentifier: mockYieldTokenIdentifier, + outVaultIdentifier: moetIdentifier, + vaultSourceStoragePath: /storage/moetTokenVault_0x0000000000000007, + priceRatio: 1.0 + ) + // user1 setup - deposits FLOW, borrows MOET let user1 = Test.createAccount() setupMoetVault(user1, beFailed: false) @@ -584,6 +806,14 @@ fun testManualLiquidation_supportedCollateralTypeNotInPosition() { // cause undercollateralization for user1 by dropping FLOW price let newPrice = 0.5 // $/FLOW setMockOraclePrice(signer: protocolAccount, forTokenIdentifier: flowTokenIdentifier, price: newPrice) + // Update DEX price to match oracle + addMockDexSwapper( + signer: protocolAccount, + inVaultIdentifier: flowTokenIdentifier, + outVaultIdentifier: moetIdentifier, + vaultSourceStoragePath: /storage/moetTokenVault_0x0000000000000007, + priceRatio: newPrice + ) let hAfterPrice = getPositionHealth(pid: pid1, beFailed: false) // execute liquidation @@ -616,15 +846,231 @@ fun testManualLiquidation_liquidationPaused() {} access(all) fun testManualLiquidation_liquidationWarmup() {} -/// Liquidations should fail if DEX price and oracle price diverge by too much. +/// Liquidations should succeed when DEX/oracle price divergence is within threshold. access(all) -fun testManualLiquidation_dexOraclePriceDivergence() {} +fun testManualLiquidation_dexOraclePriceDivergence_withinThreshold() { + safeReset() + let pid: UInt64 = 0 -/// Should be able to liquidate to below target health while increasing health factor. + // 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) + + // cause undercollateralization + let oraclePrice = 0.7 // $/FLOW + setMockOraclePrice(signer: Test.getAccount(0x0000000000000007), forTokenIdentifier: flowTokenIdentifier, price: oraclePrice) + + // Set DEX price to 0.68 (2.94% divergence: (0.7-0.68)/0.68 = 0.0294 = 2.94%) + let dexPriceRatio = 0.68 + addMockDexSwapper( + signer: Test.getAccount(0x0000000000000007), + inVaultIdentifier: flowTokenIdentifier, + outVaultIdentifier: moetIdentifier, + vaultSourceStoragePath: /storage/moetTokenVault_0x0000000000000007, + priceRatio: dexPriceRatio + ) + + let health = getPositionHealth(pid: pid, beFailed: false) + + // execute liquidation + let liquidator = Test.createAccount() + setupMoetVault(liquidator, beFailed: false) + mintMoet(signer: Test.getAccount(0x0000000000000007), to: liquidator.address, amount: 1000.0, beFailed: false) + + // Repay MOET to seize FLOW + // DEX quote would require: 50/0.68 = 73.53 FLOW + // Liquidator offers 72 FLOW < 73.53 FLOW (better price) + let repayAmount = 50.0 + let seizeAmount = 72.0 + let liqRes = _executeTransaction( + "../transactions/flow-credit-market/pool-management/manual_liquidation.cdc", + [pid, Type<@MOET.Vault>().identifier, flowTokenIdentifier, seizeAmount, repayAmount], + liquidator + ) + // Should succeed because divergence is within threshold + Test.expect(liqRes, Test.beSucceeded()) +} + +/// Liquidations should fail when DEX price is below oracle and divergence exceeds threshold. access(all) -fun testManualLiquidation_increaseHealthBelowTarget() {} +fun testManualLiquidation_dexOraclePriceDivergence_dexBelowOracle() { + safeReset() + let pid: UInt64 = 0 -/// Should be able to liquidate to exactly target health + let user = Test.createAccount() + setupMoetVault(user, beFailed: false) + transferFlowTokens(to: user, amount: 1000.0) + createWrappedPosition(signer: user, amount: 1000.0, vaultStoragePath: /storage/flowTokenVault, pushToDrawDownSink: true) + + // cause undercollateralization + setMockOraclePrice(signer: Test.getAccount(0x0000000000000007), forTokenIdentifier: flowTokenIdentifier, price: 0.7) + + // Set DEX price to 0.66 (6.06% divergence: (0.7-0.66)/0.66 = 0.0606 = 6.06%) + addMockDexSwapper( + signer: Test.getAccount(0x0000000000000007), + inVaultIdentifier: flowTokenIdentifier, + outVaultIdentifier: moetIdentifier, + vaultSourceStoragePath: /storage/moetTokenVault_0x0000000000000007, + priceRatio: 0.66 + ) + + let liquidator = Test.createAccount() + setupMoetVault(liquidator, beFailed: false) + mintMoet(signer: Test.getAccount(0x0000000000000007), to: liquidator.address, amount: 1000.0, beFailed: false) + + let liqRes = _executeTransaction( + "../transactions/flow-credit-market/pool-management/manual_liquidation.cdc", + [pid, Type<@MOET.Vault>().identifier, flowTokenIdentifier, 70.0, 50.0], + liquidator + ) + // Should fail because divergence exceeds threshold + Test.expect(liqRes, Test.beFailed()) + Test.assertError(liqRes, errorMessage: "Too large difference between dex/oracle prices") +} + +/// Liquidations should fail when DEX price is above oracle and divergence exceeds threshold. access(all) -fun testManualLiquidation_liquidateToTarget() {} +fun testManualLiquidation_dexOraclePriceDivergence_dexAboveOracle() { + safeReset() + let pid: UInt64 = 0 + + let user = Test.createAccount() + setupMoetVault(user, beFailed: false) + transferFlowTokens(to: user, amount: 1000.0) + createWrappedPosition(signer: user, amount: 1000.0, vaultStoragePath: /storage/flowTokenVault, pushToDrawDownSink: true) + + // cause undercollateralization + setMockOraclePrice(signer: Test.getAccount(0x0000000000000007), forTokenIdentifier: flowTokenIdentifier, price: 0.7) + + // Set DEX price to 0.74 (5.71% divergence above oracle: (0.74-0.7)/0.7 = 0.0571 = 5.71%) + addMockDexSwapper( + signer: Test.getAccount(0x0000000000000007), + inVaultIdentifier: flowTokenIdentifier, + outVaultIdentifier: moetIdentifier, + vaultSourceStoragePath: /storage/moetTokenVault_0x0000000000000007, + priceRatio: 0.74 + ) + + let liquidator = Test.createAccount() + setupMoetVault(liquidator, beFailed: false) + mintMoet(signer: Test.getAccount(0x0000000000000007), to: liquidator.address, amount: 1000.0, beFailed: false) + + let liqRes = _executeTransaction( + "../transactions/flow-credit-market/pool-management/manual_liquidation.cdc", + [pid, Type<@MOET.Vault>().identifier, flowTokenIdentifier, 66.0, 50.0], + liquidator + ) + // Should fail because divergence exceeds threshold + Test.expect(liqRes, Test.beFailed()) + Test.assertError(liqRes, errorMessage: "Too large difference between dex/oracle prices") +} + +/// Liquidation should fail if liquidator offer is worse than DEX price. +access(all) +fun testManualLiquidation_liquidatorOfferWorseThanDex() { + 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) + + // cause undercollateralization + let newPrice = 0.7 // $/FLOW + setMockOraclePrice(signer: Test.getAccount(0x0000000000000007), forTokenIdentifier: flowTokenIdentifier, price: newPrice) + + // Update DEX price to match oracle + addMockDexSwapper( + signer: Test.getAccount(0x0000000000000007), + inVaultIdentifier: flowTokenIdentifier, + outVaultIdentifier: moetIdentifier, + vaultSourceStoragePath: /storage/moetTokenVault_0x0000000000000007, + priceRatio: newPrice + ) + + let health = getPositionHealth(pid: pid, beFailed: false) + Test.assert(health < 1.0, message: "position should be unhealthy") + + // execute liquidation + let liquidator = Test.createAccount() + setupMoetVault(liquidator, beFailed: false) + mintMoet(signer: Test.getAccount(0x0000000000000007), to: liquidator.address, amount: 1000.0, beFailed: false) + + // Liquidator offers worse price than DEX + // DEX quote: 50/0.7 = 71.43 FLOW + // Liquidator offers 75 FLOW > 71.43 FLOW (worse price) + let repayAmount = 50.0 + let seizeAmount = 75.0 + let liqRes = _executeTransaction( + "../transactions/flow-credit-market/pool-management/manual_liquidation.cdc", + [pid, Type<@MOET.Vault>().identifier, flowTokenIdentifier, seizeAmount, repayAmount], + liquidator + ) + // Should fail because liquidator offer is worse than DEX + Test.expect(liqRes, Test.beFailed()) + Test.assertError(liqRes, errorMessage: "Liquidation offer must be better than that offered by DEX") +} + +/// Liquidation should fail when DEX/oracle divergence is too high, even when liquidator offer is competitive. +access(all) +fun testManualLiquidation_combinedEdgeCase() { + 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) + + // cause undercollateralization + let oraclePrice = 0.7 // $/FLOW + setMockOraclePrice(signer: Test.getAccount(0x0000000000000007), forTokenIdentifier: flowTokenIdentifier, price: oraclePrice) + + // Set DEX price to 0.64 (9.375% divergence: (0.7-0.64)/0.64 = 0.09375 = 9.375%) + let dexPriceRatio = 0.64 + addMockDexSwapper( + signer: Test.getAccount(0x0000000000000007), + inVaultIdentifier: flowTokenIdentifier, + outVaultIdentifier: moetIdentifier, + vaultSourceStoragePath: /storage/moetTokenVault_0x0000000000000007, + priceRatio: dexPriceRatio + ) + + let health = getPositionHealth(pid: pid, beFailed: false) + Test.assert(health < 1.0, message: "position should be unhealthy") + + // execute liquidation + let liquidator = Test.createAccount() + setupMoetVault(liquidator, beFailed: false) + mintMoet(signer: Test.getAccount(0x0000000000000007), to: liquidator.address, amount: 1000.0, beFailed: false) + + // Liquidator provides better price than DEX but divergence is too high + // DEX quote: 50/0.64 = 78.125 FLOW + // Liquidator offers 75 FLOW < 78.125 FLOW (better than DEX) + // But divergence is 9.375% which exceeds 3% threshold + let repayAmount = 50.0 + let seizeAmount = 75.0 + let liqRes = _executeTransaction( + "../transactions/flow-credit-market/pool-management/manual_liquidation.cdc", + [pid, Type<@MOET.Vault>().identifier, flowTokenIdentifier, seizeAmount, repayAmount], + liquidator + ) + // Should fail because DEX/oracle divergence is too high, even though liquidator offer is competitive + Test.expect(liqRes, Test.beFailed()) + Test.assertError(liqRes, errorMessage: "Too large difference between dex/oracle prices") +} diff --git a/cadence/tests/test_helpers.cdc b/cadence/tests/test_helpers.cdc index d1ea14f..b575ba3 100644 --- a/cadence/tests/test_helpers.cdc +++ b/cadence/tests/test_helpers.cdc @@ -269,6 +269,22 @@ fun setMockOraclePrice(signer: Test.TestAccount, forTokenIdentifier: String, pri Test.expect(setRes, Test.beSucceeded()) } +access(all) +fun addMockDexSwapper( + signer: Test.TestAccount, + inVaultIdentifier: String, + outVaultIdentifier: String, + vaultSourceStoragePath: StoragePath, + priceRatio: UFix64 +) { + let addRes = _executeTransaction( + "./transactions/mock-dex-swapper/add_swapper.cdc", + [inVaultIdentifier, outVaultIdentifier, vaultSourceStoragePath, priceRatio], + signer + ) + Test.expect(addRes, Test.beSucceeded()) +} + access(all) fun addSupportedTokenZeroRateCurve( signer: Test.TestAccount, diff --git a/cadence/tests/transactions/mock-dex-swapper/add_swapper.cdc b/cadence/tests/transactions/mock-dex-swapper/add_swapper.cdc new file mode 100644 index 0000000..5ed5c86 --- /dev/null +++ b/cadence/tests/transactions/mock-dex-swapper/add_swapper.cdc @@ -0,0 +1,44 @@ +import "FungibleToken" +import "MockDexSwapper" + +/// TEST-ONLY: Adds a swapper to MockDexSwapper for a specific token pair +/// +/// @param inVaultIdentifier: The input Vault's Type identifier (e.g., Type<@FlowToken.Vault>().identifier) +/// @param outVaultIdentifier: The output Vault's Type identifier (e.g., Type<@MOET.Vault>().identifier) +/// @param vaultSourceStoragePath: The storage path of the vault to use as the output source (from signer's storage) +/// @param priceRatio: The price ratio for the swap (out per unit in) +transaction( + inVaultIdentifier: String, + outVaultIdentifier: String, + vaultSourceStoragePath: StoragePath, + priceRatio: UFix64 +) { + let inType: Type + let outType: Type + let vaultSourceCap: Capability + + prepare(signer: auth(IssueStorageCapabilityController) &Account) { + self.inType = CompositeType(inVaultIdentifier) ?? panic("Invalid inVaultIdentifier \(inVaultIdentifier)") + self.outType = CompositeType(outVaultIdentifier) ?? panic("Invalid outVaultIdentifier \(outVaultIdentifier)") + + // Get a capability to the vault source from the signer's storage + self.vaultSourceCap = signer.capabilities.storage + .issue(vaultSourceStoragePath) + + assert(self.vaultSourceCap.check(), message: "Invalid vault source capability") + } + + execute { + // Create the swapper + let swapper = MockDexSwapper.Swapper( + inVault: self.inType, + outVault: self.outType, + vaultSource: self.vaultSourceCap, + priceRatio: priceRatio, + uniqueID: nil + ) + + // Add the swapper to MockDexSwapper + MockDexSwapper._addSwapper(swapper: swapper) + } +} diff --git a/cadence/transactions/flow-credit-market/pool-factory/create_and_store_pool.cdc b/cadence/transactions/flow-credit-market/pool-factory/create_and_store_pool.cdc index dd7e926..b988b89 100644 --- a/cadence/transactions/flow-credit-market/pool-factory/create_and_store_pool.cdc +++ b/cadence/transactions/flow-credit-market/pool-factory/create_and_store_pool.cdc @@ -3,6 +3,7 @@ import "FungibleToken" import "DeFiActions" import "FlowCreditMarket" import "MockOracle" +import "MockDexSwapper" /// THIS TRANSACTION IS NOT INTENDED FOR PRODUCTION /// !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! @@ -16,15 +17,17 @@ transaction(defaultTokenIdentifier: String) { let factory: &FlowCreditMarket.PoolFactory let defaultToken: Type let oracle: {DeFiActions.PriceOracle} + let dex: {DeFiActions.SwapperProvider} prepare(signer: auth(BorrowValue) &Account) { self.factory = signer.storage.borrow<&FlowCreditMarket.PoolFactory>(from: FlowCreditMarket.PoolFactoryPath) ?? panic("Could not find PoolFactory in signer's account") self.defaultToken = CompositeType(defaultTokenIdentifier) ?? panic("Invalid defaultTokenIdentifier \(defaultTokenIdentifier)") self.oracle = MockOracle.PriceOracle() + self.dex = MockDexSwapper.SwapperProvider() } execute { - self.factory.createPool(defaultToken: self.defaultToken, priceOracle: self.oracle) + self.factory.createPool(defaultToken: self.defaultToken, priceOracle: self.oracle, dex: self.dex) } } diff --git a/flow.json b/flow.json index a72b42d..99d878a 100644 --- a/flow.json +++ b/flow.json @@ -75,6 +75,7 @@ "Burner": { "source": "mainnet://f233dcee88fe0abe.Burner", "hash": "71af18e227984cd434a3ad00bb2f3618b76482842bae920ee55662c37c8bf331", + "block_height": 139085361, "aliases": { "emulator": "f8d6e0586b0a20c7", "mainnet": "f233dcee88fe0abe", @@ -84,6 +85,7 @@ "FlowFees": { "source": "mainnet://f919ee77447b7497.FlowFees", "hash": "341cc0f3cc847d6b787c390133f6a5e6c867c111784f09c5c0083c47f2f1df64", + "block_height": 139085361, "aliases": { "emulator": "e5a8b7f23e8b548f", "mainnet": "f919ee77447b7497", @@ -93,6 +95,7 @@ "FlowStorageFees": { "source": "mainnet://e467b9dd11fa00df.FlowStorageFees", "hash": "a92c26fb2ea59725441fa703aa4cd811e0fc56ac73d649a8e12c1e72b67a8473", + "block_height": 139085361, "aliases": { "emulator": "f8d6e0586b0a20c7", "mainnet": "e467b9dd11fa00df", @@ -102,6 +105,7 @@ "FlowToken": { "source": "mainnet://1654653399040a61.FlowToken", "hash": "f82389e2412624ffa439836b00b42e6605b0c00802a4e485bc95b8930a7eac38", + "block_height": 139085361, "aliases": { "emulator": "0ae53cb6e3f42a79", "mainnet": "1654653399040a61", @@ -110,7 +114,8 @@ }, "FlowTransactionScheduler": { "source": "mainnet://e467b9dd11fa00df.FlowTransactionScheduler", - "hash": "c701f26f6a8e993b2573ec8700142f61c9ca936b199af8cc75dee7d9b19c9e95", + "hash": "23157cf7d70534e45b0ab729133232d0ffb3cdae52661df1744747cb1f8c0495", + "block_height": 139085361, "aliases": { "emulator": "f8d6e0586b0a20c7", "mainnet": "e467b9dd11fa00df", @@ -120,6 +125,7 @@ "FungibleToken": { "source": "mainnet://f233dcee88fe0abe.FungibleToken", "hash": "4b74edfe7d7ddfa70b703c14aa731a0b2e7ce016ce54d998bfd861ada4d240f6", + "block_height": 139085361, "aliases": { "emulator": "ee82856bf20e2aa6", "mainnet": "f233dcee88fe0abe", @@ -129,6 +135,7 @@ "FungibleTokenMetadataViews": { "source": "mainnet://f233dcee88fe0abe.FungibleTokenMetadataViews", "hash": "70477f80fd7678466c224507e9689f68f72a9e697128d5ea54d19961ec856b3c", + "block_height": 139085361, "aliases": { "emulator": "ee82856bf20e2aa6", "mainnet": "f233dcee88fe0abe", @@ -138,6 +145,7 @@ "MetadataViews": { "source": "mainnet://1d7e57aa55817448.MetadataViews", "hash": "b290b7906d901882b4b62e596225fb2f10defb5eaaab4a09368f3aee0e9c18b1", + "block_height": 139085361, "aliases": { "emulator": "f8d6e0586b0a20c7", "mainnet": "1d7e57aa55817448", @@ -147,6 +155,7 @@ "NonFungibleToken": { "source": "mainnet://1d7e57aa55817448.NonFungibleToken", "hash": "a258de1abddcdb50afc929e74aca87161d0083588f6abf2b369672e64cf4a403", + "block_height": 139085361, "aliases": { "emulator": "f8d6e0586b0a20c7", "mainnet": "1d7e57aa55817448", @@ -156,6 +165,7 @@ "ViewResolver": { "source": "mainnet://1d7e57aa55817448.ViewResolver", "hash": "374a1994046bac9f6228b4843cb32393ef40554df9bd9907a702d098a2987bde", + "block_height": 139085361, "aliases": { "emulator": "f8d6e0586b0a20c7", "mainnet": "1d7e57aa55817448",