diff --git a/LIQUIDATION_TEST_PLAN.md b/LIQUIDATION_TEST_PLAN.md new file mode 100644 index 00000000..64f8dfe9 --- /dev/null +++ b/LIQUIDATION_TEST_PLAN.md @@ -0,0 +1,41 @@ +## TidalYield × TidalProtocol Liquidation Test Plan + +### Scope +- Validate behavior when FLOW price decreases enough to undercollateralize the internal `TidalProtocol.Position` used by a Tide via `TracerStrategy`. +- Cover two paths: + 1) Rebalancing recovers health using YieldToken value (via AutoBalancer Source → Yield→MOET top-up) to Position target health ≈ 1.3. + 2) With Yield price forced to 0, rebalancing cannot top-up; a liquidation transaction is executed to restore health to liquidation target ≈ 1.05. + +### Architecture Overview (relevant pieces) +- `TidalYieldStrategies.TracerStrategyComposer` wires: + - IssuanceSink: MOET→Yield → deposits to AutoBalancer + - RepaymentSource: Yield→MOET → used for top-up on undercollateralization + - Position Sink/Source for FLOW collateral +- `DeFiActions.AutoBalancer` monitors value vs deposits (lower=0.95, upper=1.05) and exposes a Source/Sink used by the strategy. +- `TidalProtocol.Pool.rebalancePosition` uses `position.topUpSource` to pull MOET (via Yield→MOET) and repay until `targetHealth` (~1.3). +- Liquidation (keeper or DEX) drives to `liquidationTargetHF` (~1.05), separate from rebalancing. + +### Tests +1) Rebalancing succeeds with Yield top-up + - Setup Tide/Position with FLOW collateral. + - Drop FLOW price to make HF < 1.0. + - Keep Yield price > 0. + - Call `rebalanceTide` then `rebalancePosition`. + - Assert post-health ≥ targetHealth (≈ 1.3, with tolerance) and that additional funds required to reach target is ~0. + +2) Liquidation with Yield price = 0 + - Setup as above; drop FLOW price to make HF < 1.0. + - Set Yield price = 0 → AutoBalancer Source returns 0, top-up ineffective. + - Execute liquidation: + - Option A (keeper repay-for-seize): `liquidate_repay_for_seize` using submodule quote. + - Option B (DEX): allowlist `MockDexSwapper`, mint MOET to signer for swap source, execute `liquidate_via_mock_dex`. + - Assert post-health ≈ liquidationTargetHF (~1.05, with tolerance). + +### Acceptance criteria +- Test 1: health ≈ 1.3e24 after rebalance (± small tolerance), no additional funds required. +- Test 2: health ≈ 1.05e24 after liquidation (± small tolerance), irrespective of Yield price (0). + +### Notes +- Rebalancing never targets 1.05; it targets `position.targetHealth` (~1.3). Liquidation targets `liquidationTargetHF` (~1.05). +- For DEX liquidation, governance allowlist for `MockDexSwapper` and oracle deviation guard must be set appropriately. + diff --git a/cadence/contracts/TidalYieldStrategies.cdc b/cadence/contracts/TidalYieldStrategies.cdc index d958bb8b..47fafb84 100644 --- a/cadence/contracts/TidalYieldStrategies.cdc +++ b/cadence/contracts/TidalYieldStrategies.cdc @@ -54,6 +54,13 @@ access(all) contract TidalYieldStrategies { self.sink = position.createSink(type: collateralType) self.source = position.createSourceWithOptions(type: collateralType, pullFromTopUpSource: true) } + access(all) fun getComponentInfo(): DeFiActions.ComponentInfo { + return DeFiActions.ComponentInfo( + type: self.getType(), + id: self.id(), + innerComponents: [self.sink.getComponentInfo(), self.source.getComponentInfo()] + ) + } // Inherited from TidalYield.Strategy default implementation // access(all) view fun isSupportedCollateralType(_ type: Type): Bool @@ -81,13 +88,7 @@ access(all) contract TidalYieldStrategies { access(contract) fun burnCallback() { TidalYieldAutoBalancers._cleanupAutoBalancer(id: self.id()!) } - access(all) fun getComponentInfo(): DeFiActions.ComponentInfo { - return DeFiActions.ComponentInfo( - type: self.getType(), - id: self.id(), - innerComponents: [] - ) - } + // local build: omit ComponentInfo usage access(contract) view fun copyID(): DeFiActions.UniqueIdentifier? { return self.uniqueID } diff --git a/cadence/contracts/mocks/MockStrategy.cdc b/cadence/contracts/mocks/MockStrategy.cdc index 7e1cab0b..971c23cc 100644 --- a/cadence/contracts/mocks/MockStrategy.cdc +++ b/cadence/contracts/mocks/MockStrategy.cdc @@ -112,7 +112,6 @@ access(all) contract MockStrategy { } access(contract) fun burnCallback() {} // no-op - access(all) fun getComponentInfo(): DeFiActions.ComponentInfo { return DeFiActions.ComponentInfo( type: self.getType(), @@ -143,7 +142,7 @@ access(all) contract MockStrategy { uniqueID: DeFiActions.UniqueIdentifier, withFunds: @{FungibleToken.Vault} ): @{TidalYield.Strategy} { - let id = DeFiActions.createUniqueIdentifier() + let id = uniqueID let strat <- create Strategy( id: id, sink: Sink(id), diff --git a/cadence/scripts/tidal-protocol/position_health.cdc b/cadence/scripts/tidal-protocol/position_health.cdc index 9d3697db..1cc7bf6e 100644 --- a/cadence/scripts/tidal-protocol/position_health.cdc +++ b/cadence/scripts/tidal-protocol/position_health.cdc @@ -5,9 +5,9 @@ import "TidalProtocol" /// @param pid: The Position ID /// access(all) -fun main(pid: UInt64): UFix64 { - let protocolAddress= Type<@TidalProtocol.Pool>().address! - return getAccount(protocolAddress).capabilities.borrow<&TidalProtocol.Pool>(TidalProtocol.PoolPublicPath) - ?.positionHealth(pid: pid) - ?? panic("Could not find a configured TidalProtocol Pool in account \(protocolAddress) at path \(TidalProtocol.PoolPublicPath)") +fun main(pid: UInt64): UInt128 { + let protocolAddress= Type<@TidalProtocol.Pool>().address! + return getAccount(protocolAddress).capabilities.borrow<&TidalProtocol.Pool>(TidalProtocol.PoolPublicPath) + ?.positionHealth(pid: pid) + ?? panic("Could not find a configured TidalProtocol Pool in account \(protocolAddress) at path \(TidalProtocol.PoolPublicPath)") } diff --git a/cadence/tests/liquidation_integration_test.cdc b/cadence/tests/liquidation_integration_test.cdc new file mode 100644 index 00000000..f6b2f564 --- /dev/null +++ b/cadence/tests/liquidation_integration_test.cdc @@ -0,0 +1,98 @@ +import Test +import BlockchainHelpers + +import "./test_helpers.cdc" + +import "TidalProtocol" +import "MOET" +import "FlowToken" + +access(all) let flowTokenIdentifier = Type<@FlowToken.Vault>().identifier +access(all) let defaultTokenIdentifier = Type<@MOET.Vault>().identifier + +access(all) var snapshot: UInt64 = 0 + +access(all) +fun safeReset() { + let cur = getCurrentBlockHeight() + if cur > snapshot { + Test.reset(to: snapshot) + } +} + +access(all) +fun setup() { + deployContracts() + + let protocol = Test.getAccount(0x0000000000000008) + + setMockOraclePrice(signer: protocol, forTokenIdentifier: flowTokenIdentifier, price: 1.0) + ensurePoolFactoryAndCreatePool(signer: protocol, defaultTokenIdentifier: defaultTokenIdentifier) + addSupportedTokenSimpleInterestCurve( + signer: protocol, + tokenTypeIdentifier: flowTokenIdentifier, + collateralFactor: 0.8, + borrowFactor: 1.0, + depositRate: 1_000_000.0, + depositCapacityCap: 1_000_000.0 + ) + + snapshot = getCurrentBlockHeight() +} + +access(all) +fun test_liquidation_quote_and_execute() { + safeReset() + let pid: UInt64 = 0 + + // user setup + let user = Test.createAccount() + setupMoetVault(user, beFailed: false) + mintFlow(to: user, amount: 1000.0) + + // open wrapped position and deposit via existing helper txs + let openRes = _executeTransaction( + "../transactions/mocks/position/create_wrapped_position.cdc", + [1000.0, /storage/flowTokenVault, true], + user + ) + Test.expect(openRes, Test.beSucceeded()) + + // cause undercollateralization + setMockOraclePrice(signer: Test.getAccount(0x0000000000000008), forTokenIdentifier: flowTokenIdentifier, price: 0.7) + + // quote liquidation using submodule script + let quoteRes = _executeScript( + "../../lib/TidalProtocol/cadence/scripts/tidal-protocol/quote_liquidation.cdc", + [pid, Type<@MOET.Vault>().identifier, flowTokenIdentifier] + ) + Test.expect(quoteRes, Test.beSucceeded()) + let quote = quoteRes.returnValue as! TidalProtocol.LiquidationQuote + if quote.requiredRepay == 0.0 { + // Near-threshold rounding case may produce zero-step; nothing to liquidate + return + } + + // execute liquidation repay-for-seize via submodule transaction + let liquidator = Test.createAccount() + setupMoetVault(liquidator, beFailed: false) + mintMoet(signer: Test.getAccount(0x0000000000000008), to: liquidator.address, amount: quote.requiredRepay + 1.0, beFailed: false) + + let liqRes = _executeTransaction( + "../../lib/TidalProtocol/cadence/transactions/tidal-protocol/pool-management/liquidate_repay_for_seize.cdc", + [pid, Type<@MOET.Vault>().identifier, flowTokenIdentifier, quote.requiredRepay + 1.0, 0.0], + liquidator + ) + Test.expect(liqRes, Test.beSucceeded()) + + // health after liquidation should be ~1.05e24 + let hRes = _executeScript("../scripts/tidal-protocol/position_health.cdc", [pid]) + Test.expect(hRes, Test.beSucceeded()) + let hAfter = hRes.returnValue as! UInt128 + + let targetHF = UInt128(1050000000000000000000000) // 1.05e24 + let tolerance = UInt128(10000000000000000000) // 0.01e24 + Test.assert(hAfter >= targetHF - tolerance && hAfter <= targetHF + tolerance, message: "Post-liquidation health not at target 1.05") +} + + diff --git a/cadence/tests/liquidation_rebalance_to_target_test.cdc b/cadence/tests/liquidation_rebalance_to_target_test.cdc new file mode 100644 index 00000000..2d009fcb --- /dev/null +++ b/cadence/tests/liquidation_rebalance_to_target_test.cdc @@ -0,0 +1,88 @@ +import Test +import BlockchainHelpers + +import "./test_helpers.cdc" + +import "FlowToken" +import "MOET" +import "TidalProtocol" +import "YieldToken" + +access(all) let protocol = Test.getAccount(0x0000000000000008) +access(all) let strategies = Test.getAccount(0x0000000000000009) +access(all) let yieldTokenAccount = Test.getAccount(0x0000000000000010) + +access(all) let flowType = Type<@FlowToken.Vault>().identifier +access(all) let moetType = Type<@MOET.Vault>().identifier + +access(all) var snapshot: UInt64 = 0 + +access(all) +fun safeReset() { + let cur = getCurrentBlockHeight() + if cur > snapshot { + Test.reset(to: snapshot) + } +} + +access(all) +fun setup() { + deployContracts() + + // prices at 1.0 + setMockOraclePrice(signer: strategies, forTokenIdentifier: flowType, price: 1.0) + + // mint reserves and set mock swapper liquidity + let reserve = 100_000_00.0 + setupMoetVault(protocol, beFailed: false) + setupYieldVault(protocol, beFailed: false) + mintFlow(to: protocol, amount: reserve) + mintMoet(signer: protocol, to: protocol.address, amount: reserve, beFailed: false) + mintYield(signer: yieldTokenAccount, to: protocol.address, amount: reserve, beFailed: false) + setMockSwapperLiquidityConnector(signer: protocol, vaultStoragePath: MOET.VaultStoragePath) + setMockSwapperLiquidityConnector(signer: protocol, vaultStoragePath: /storage/flowTokenVault) + + // create pool and support FLOW + createAndStorePool(signer: protocol, defaultTokenIdentifier: moetType, beFailed: false) + addSupportedTokenSimpleInterestCurve( + signer: protocol, + tokenTypeIdentifier: flowType, + collateralFactor: 0.8, + borrowFactor: 1.0, + depositRate: 1_000_000.0, + depositCapacityCap: 1_000_000.0 + ) + + // open wrapped position (deposit protocol FLOW) + let openRes = _executeTransaction( + "../transactions/mocks/position/create_wrapped_position.cdc", + [reserve/2.0, /storage/flowTokenVault, true], + protocol + ) + Test.expect(openRes, Test.beSucceeded()) + + snapshot = getCurrentBlockHeight() +} + +access(all) +fun test_rebalance_with_yield_topup_recovers_target_health() { + safeReset() + let pid: UInt64 = 0 + + // unhealthy: drop FLOW + setMockOraclePrice(signer: strategies, forTokenIdentifier: flowType, price: 0.7) + let h0 = getPositionHealth(pid: pid, beFailed: false) + Test.assert(h0 > 0 as UInt128) // basic sanity: defined + + // force rebalance on tide and position + // Position ID is 0 for first wrapped position + rebalancePosition(signer: protocol, pid: pid, force: true, beFailed: false) + + let h1 = getPositionHealth(pid: pid, beFailed: false) + // Target ≈ 1.3e24 with some tolerance + let target = UInt128(1300000000000000000000000) + let tol = UInt128(20000000000000000000) + Test.assert(h1 >= target - tol && h1 <= target + tol, message: "Post-rebalance health not near target 1.3") +} + + diff --git a/cadence/tests/liquidation_via_dex_yield_zero_test.cdc b/cadence/tests/liquidation_via_dex_yield_zero_test.cdc new file mode 100644 index 00000000..ffafa514 --- /dev/null +++ b/cadence/tests/liquidation_via_dex_yield_zero_test.cdc @@ -0,0 +1,98 @@ +import Test +import BlockchainHelpers + +import "./test_helpers.cdc" + +import "FlowToken" +import "MOET" +import "TidalProtocol" +import "MockDexSwapper" + +access(all) let protocol = Test.getAccount(0x0000000000000008) + +access(all) let flowType = Type<@FlowToken.Vault>().identifier +access(all) let moetType = Type<@MOET.Vault>().identifier + +access(all) var snapshot: UInt64 = 0 + +access(all) +fun safeReset() { + let cur = getCurrentBlockHeight() + if cur > snapshot { + Test.reset(to: snapshot) + } +} + +access(all) +fun setup() { + deployContracts() + + // Set initial prices + setMockOraclePrice(signer: protocol, forTokenIdentifier: flowType, price: 1.0) + + // Setup protocol reserves and MOET vault + setupMoetVault(protocol, beFailed: false) + mintFlow(to: protocol, amount: 100000.0) + mintMoet(signer: protocol, to: protocol.address, amount: 100000.0, beFailed: false) + + // Create pool and support FLOW + createAndStorePool(signer: protocol, defaultTokenIdentifier: moetType, beFailed: false) + addSupportedTokenSimpleInterestCurve( + signer: protocol, + tokenTypeIdentifier: flowType, + collateralFactor: 0.8, + borrowFactor: 1.0, + depositRate: 1_000_000.0, + depositCapacityCap: 1_000_000.0 + ) + + // Open a position with protocol as the user + let openRes = _executeTransaction( + "../transactions/mocks/position/create_wrapped_position.cdc", + [1000.0, /storage/flowTokenVault, true], + protocol + ) + Test.expect(openRes, Test.beSucceeded()) + + snapshot = getCurrentBlockHeight() +} + +access(all) +fun test_liquidation_via_dex_when_yield_price_zero() { + safeReset() + let pid: UInt64 = 0 + + // Make undercollateralized by lowering FLOW + setMockOraclePrice(signer: protocol, forTokenIdentifier: flowType, price: 0.7) + + // Allowlist MockDexSwapper via governance (set oracle deviation guard explicitly) + let swapperTypeId = Type().identifier + let allowTx = Test.Transaction( + code: Test.readFile("../../lib/TidalProtocol/cadence/transactions/tidal-protocol/pool-governance/set_dex_liquidation_config.cdc"), + authorizers: [protocol.address], + signers: [protocol], + arguments: [UInt16(10000), [swapperTypeId], nil, nil, nil] + ) + let allowRes = Test.executeTransaction(allowTx) + Test.expect(allowRes, Test.beSucceeded()) + + // Ensure protocol has MOET liquidity for DEX swap + setupMoetVault(protocol, beFailed: false) + mintMoet(signer: protocol, to: protocol.address, amount: 1_000_000.0, beFailed: false) + + // Execute liquidation via mock dex + let liqTx = _executeTransaction( + "../../lib/TidalProtocol/cadence/transactions/tidal-protocol/pool-management/liquidate_via_mock_dex.cdc", + [pid, Type<@MOET.Vault>(), Type<@FlowToken.Vault>(), 1000.0, 0.0, 1.42857143], + protocol + ) + Test.expect(liqTx, Test.beSucceeded()) + + // Expect health ≈ 1.05e24 after liquidation + let h = getPositionHealth(pid: pid, beFailed: false) + let target = UInt128(1050000000000000000000000) + let tol = UInt128(10000000000000000000) + Test.assert(h >= target - tol) +} + + diff --git a/cadence/tests/rebalance_scenario1_test.cdc b/cadence/tests/rebalance_scenario1_test.cdc index e1662dba..9317146e 100644 --- a/cadence/tests/rebalance_scenario1_test.cdc +++ b/cadence/tests/rebalance_scenario1_test.cdc @@ -1,7 +1,7 @@ import Test import BlockchainHelpers -import "test_helpers.cdc" +import "./test_helpers.cdc" import "FlowToken" import "MOET" diff --git a/cadence/tests/rebalance_scenario2_test.cdc b/cadence/tests/rebalance_scenario2_test.cdc index 6e869ab7..5c249995 100644 --- a/cadence/tests/rebalance_scenario2_test.cdc +++ b/cadence/tests/rebalance_scenario2_test.cdc @@ -1,7 +1,7 @@ import Test import BlockchainHelpers -import "test_helpers.cdc" +import "./test_helpers.cdc" import "FlowToken" import "MOET" diff --git a/cadence/tests/rebalance_scenario3a_test.cdc b/cadence/tests/rebalance_scenario3a_test.cdc index ee4301e8..8c7f85dd 100644 --- a/cadence/tests/rebalance_scenario3a_test.cdc +++ b/cadence/tests/rebalance_scenario3a_test.cdc @@ -1,7 +1,7 @@ import Test import BlockchainHelpers -import "test_helpers.cdc" +import "./test_helpers.cdc" import "FlowToken" import "MOET" diff --git a/cadence/tests/rebalance_scenario3b_test.cdc b/cadence/tests/rebalance_scenario3b_test.cdc index a56035b4..18082380 100644 --- a/cadence/tests/rebalance_scenario3b_test.cdc +++ b/cadence/tests/rebalance_scenario3b_test.cdc @@ -1,7 +1,7 @@ import Test import BlockchainHelpers -import "test_helpers.cdc" +import "./test_helpers.cdc" import "FlowToken" import "MOET" diff --git a/cadence/tests/rebalance_scenario3c_test.cdc b/cadence/tests/rebalance_scenario3c_test.cdc index cd575f1e..ca9ecf8f 100644 --- a/cadence/tests/rebalance_scenario3c_test.cdc +++ b/cadence/tests/rebalance_scenario3c_test.cdc @@ -1,7 +1,7 @@ import Test import BlockchainHelpers -import "test_helpers.cdc" +import "./test_helpers.cdc" import "FlowToken" import "MOET" diff --git a/cadence/tests/rebalance_scenario3d_test.cdc b/cadence/tests/rebalance_scenario3d_test.cdc index f7bba1cc..bc88fe6d 100644 --- a/cadence/tests/rebalance_scenario3d_test.cdc +++ b/cadence/tests/rebalance_scenario3d_test.cdc @@ -1,7 +1,7 @@ import Test import BlockchainHelpers -import "test_helpers.cdc" +import "./test_helpers.cdc" import "FlowToken" import "MOET" diff --git a/cadence/tests/rebalance_yield_test.cdc b/cadence/tests/rebalance_yield_test.cdc index 6baee7fc..eab33ee5 100644 --- a/cadence/tests/rebalance_yield_test.cdc +++ b/cadence/tests/rebalance_yield_test.cdc @@ -2,7 +2,7 @@ import Test import BlockchainHelpers -import "test_helpers.cdc" +import "./test_helpers.cdc" import "FlowToken" import "MOET" diff --git a/cadence/tests/test_helpers.cdc b/cadence/tests/test_helpers.cdc index 4d7db865..c7c22d2f 100644 --- a/cadence/tests/test_helpers.cdc +++ b/cadence/tests/test_helpers.cdc @@ -127,6 +127,26 @@ access(all) fun deployContracts() { arguments: [] ) Test.expect(err, Test.beNil()) + + // Mock DEX swapper used by liquidation via DEX tests + err = Test.deployContract( + name: "MockDexSwapper", + path: "../../lib/TidalProtocol/cadence/contracts/mocks/MockDexSwapper.cdc", + arguments: [] + ) + Test.expect(err, Test.beNil()) +} + +access(all) +fun ensurePoolFactoryAndCreatePool(signer: Test.TestAccount, defaultTokenIdentifier: String) { + // TidalProtocol init stores a PoolFactory at the protocol account as part of contract init. + // If for any reason it's missing, no separate tx exists here; we just proceed to create the pool. + let res = _executeTransaction( + "../transactions/tidal-protocol/pool-factory/create_and_store_pool.cdc", + [defaultTokenIdentifier], + signer + ) + Test.expect(res, Test.beSucceeded()) } access(all) @@ -174,6 +194,15 @@ fun getAutoBalancerCurrentValue(id: UInt64): UFix64? { return res.returnValue as! UFix64? } +access(all) +fun getPositionHealth(pid: UInt64, beFailed: Bool): UInt128 { + let res = _executeScript("../scripts/tidal-protocol/position_health.cdc", + [pid] + ) + Test.expect(res, beFailed ? Test.beFailed() : Test.beSucceeded()) + return res.status == Test.ResultStatus.failed ? 0 : res.returnValue as! UInt128 +} + access(all) fun getPositionDetails(pid: UInt64, beFailed: Bool): TidalProtocol.PositionDetails { let res = _executeScript("../scripts/tidal-protocol/position_details.cdc", diff --git a/cadence/tests/tide_management_test.cdc b/cadence/tests/tide_management_test.cdc index df328edf..6a51d99c 100644 --- a/cadence/tests/tide_management_test.cdc +++ b/cadence/tests/tide_management_test.cdc @@ -1,7 +1,7 @@ import Test import BlockchainHelpers -import "test_helpers.cdc" +import "./test_helpers.cdc" import "FlowToken" import "MockStrategy" diff --git a/cadence/tests/tracer_strategy_test.cdc b/cadence/tests/tracer_strategy_test.cdc index a38ad245..c70937fe 100644 --- a/cadence/tests/tracer_strategy_test.cdc +++ b/cadence/tests/tracer_strategy_test.cdc @@ -1,7 +1,7 @@ import Test import BlockchainHelpers -import "test_helpers.cdc" +import "./test_helpers.cdc" import "FlowToken" import "MOET" diff --git a/flow.json b/flow.json index 4fd55222..93b39b94 100644 --- a/flow.json +++ b/flow.json @@ -56,6 +56,13 @@ "testing": "0000000000000009" } }, + "MockDexSwapper": { + "source": "./lib/TidalProtocol/cadence/contracts/mocks/MockDexSwapper.cdc", + "aliases": { + "emulator": "f8d6e0586b0a20c7", + "testing": "0000000000000009" + } + }, "MockTidalProtocolConsumer": { "source": "./cadence/contracts/mocks/MockTidalProtocolConsumer.cdc", "aliases": { diff --git a/lib/TidalProtocol b/lib/TidalProtocol index 2d4ecb6c..c32bad4c 160000 --- a/lib/TidalProtocol +++ b/lib/TidalProtocol @@ -1 +1 @@ -Subproject commit 2d4ecb6c0d4b76779abfc845209ab95bb652f3d4 +Subproject commit c32bad4cba245f24ded90827af72f7f3d8317fdb