Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 41 additions & 0 deletions LIQUIDATION_TEST_PLAN.md
Original file line number Diff line number Diff line change
@@ -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.

15 changes: 8 additions & 7 deletions cadence/contracts/TidalYieldStrategies.cdc
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
}
Expand Down
3 changes: 1 addition & 2 deletions cadence/contracts/mocks/MockStrategy.cdc
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down Expand Up @@ -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),
Expand Down
10 changes: 5 additions & 5 deletions cadence/scripts/tidal-protocol/position_health.cdc
Original file line number Diff line number Diff line change
Expand Up @@ -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)")
}
98 changes: 98 additions & 0 deletions cadence/tests/liquidation_integration_test.cdc
Original file line number Diff line number Diff line change
@@ -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")
}


88 changes: 88 additions & 0 deletions cadence/tests/liquidation_rebalance_to_target_test.cdc
Original file line number Diff line number Diff line change
@@ -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")
}


98 changes: 98 additions & 0 deletions cadence/tests/liquidation_via_dex_yield_zero_test.cdc
Original file line number Diff line number Diff line change
@@ -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<MockDexSwapper.Swapper>().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)
}


2 changes: 1 addition & 1 deletion cadence/tests/rebalance_scenario1_test.cdc
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import Test
import BlockchainHelpers

import "test_helpers.cdc"
import "./test_helpers.cdc"

import "FlowToken"
import "MOET"
Expand Down
Loading