diff --git a/cadence/contracts/FlowYieldVaults.cdc b/cadence/contracts/FlowYieldVaults.cdc index 34dfa5d..4856845 100644 --- a/cadence/contracts/FlowYieldVaults.cdc +++ b/cadence/contracts/FlowYieldVaults.cdc @@ -105,6 +105,10 @@ access(all) contract FlowYieldVaults { "Invalid Vault returns - requests \(ofToken.identifier) but returned \(result.getType().identifier)" } } + /// Closes the underlying position by repaying all debt and returning all collateral. + /// This method uses the AutoBalancer as a repayment source to swap yield tokens to debt tokens as needed. + /// Returns a Vault containing all collateral including any dust residuals. + access(FungibleToken.Withdraw) fun closePosition(collateralType: Type): @{FungibleToken.Vault} } /// StrategyComposer @@ -340,6 +344,23 @@ access(all) contract FlowYieldVaults { return <- res } + /// Closes the YieldVault by repaying all debt on the underlying position and returning all collateral. + /// This method properly closes the FlowALP position by using the AutoBalancer to swap yield tokens + /// to MOET for debt repayment, then returns all collateral including any dust residuals. + access(FungibleToken.Withdraw) fun close(): @{FungibleToken.Vault} { + let collateral <- self._borrowStrategy().closePosition(collateralType: self.vaultType) + + emit WithdrawnFromYieldVault( + id: self.uniqueID.id, + strategyType: self.getStrategyType(), + tokenType: collateral.getType().identifier, + amount: collateral.balance, + owner: self.owner?.address, + toUUID: collateral.uuid + ) + + return <- collateral + } /// Returns an authorized reference to the encapsulated Strategy access(self) view fun _borrowStrategy(): auth(FungibleToken.Withdraw) &{Strategy} { return &self.strategy as auth(FungibleToken.Withdraw) &{Strategy}? @@ -465,8 +486,9 @@ access(all) contract FlowYieldVaults { let yieldVault = (&self.yieldVaults[id] as auth(FungibleToken.Withdraw) &YieldVault?)! return <- yieldVault.withdraw(amount: amount) } - /// Withdraws and returns all available funds from the specified YieldVault, destroying the YieldVault and access to any - /// Strategy-related wiring with it + /// Closes the YieldVault by repaying all debt and returning all collateral, then destroys the YieldVault. + /// This properly closes the underlying FlowALP position by using the AutoBalancer to swap yield tokens + /// to MOET for debt repayment, ensuring all collateral (including dust) is returned to the caller. access(FungibleToken.Withdraw) fun closeYieldVault(_ id: UInt64): @{FungibleToken.Vault} { pre { self.yieldVaults[id] != nil: @@ -474,7 +496,7 @@ access(all) contract FlowYieldVaults { } let yieldVault <- self._withdrawYieldVault(id: id) - let res <- yieldVault.withdraw(amount: yieldVault.getYieldVaultBalance()) + let res <- yieldVault.close() Burner.burn(<-yieldVault) return <-res } diff --git a/cadence/contracts/FlowYieldVaultsAutoBalancers.cdc b/cadence/contracts/FlowYieldVaultsAutoBalancers.cdc index 5e127d5..d34db08 100644 --- a/cadence/contracts/FlowYieldVaultsAutoBalancers.cdc +++ b/cadence/contracts/FlowYieldVaultsAutoBalancers.cdc @@ -44,6 +44,32 @@ access(all) contract FlowYieldVaultsAutoBalancers { return self.account.capabilities.borrow<&DeFiActions.AutoBalancer>(publicPath) } + /// Forces rebalancing on an AutoBalancer before close operations. + /// This ensures sufficient liquid funds are available without mid-operation rebalancing. + /// + /// @param id: The yield vault/AutoBalancer ID + /// + access(account) fun rebalanceAutoBalancer(id: UInt64) { + let storagePath = self.deriveAutoBalancerPath(id: id, storage: true) as! StoragePath + if let autoBalancer = self.account.storage.borrow(from: storagePath) { + autoBalancer.rebalance(force: true) + } + } + + /// Creates a source from an AutoBalancer for external use (e.g., position close operations). + /// This allows bypassing position topUpSource to avoid circular dependency issues. + /// + /// @param id: The yield vault/AutoBalancer ID + /// @return Source that can withdraw from the AutoBalancer, or nil if not found + /// + access(account) fun createExternalSource(id: UInt64): {DeFiActions.Source}? { + let storagePath = self.deriveAutoBalancerPath(id: id, storage: true) as! StoragePath + if let autoBalancer = self.account.storage.borrow(from: storagePath) { + return autoBalancer.createBalancerSource() + } + return nil + } + /// Checks if an AutoBalancer has at least one active (Scheduled) transaction. /// Used by Supervisor to detect stuck yield vaults that need recovery. /// diff --git a/cadence/contracts/FlowYieldVaultsStrategiesV2.cdc b/cadence/contracts/FlowYieldVaultsStrategiesV2.cdc index 05f6135..70386e2 100644 --- a/cadence/contracts/FlowYieldVaultsStrategiesV2.cdc +++ b/cadence/contracts/FlowYieldVaultsStrategiesV2.cdc @@ -42,7 +42,7 @@ access(all) contract FlowYieldVaultsStrategiesV2 { access(all) let univ3RouterEVMAddress: EVM.EVMAddress access(all) let univ3QuoterEVMAddress: EVM.EVMAddress - access(all) let config: {String: AnyStruct} + access(all) let config: {String: AnyStruct} /// Canonical StoragePath where the StrategyComposerIssuer should be stored access(all) let IssuerStoragePath: StoragePath @@ -81,7 +81,15 @@ access(all) contract FlowYieldVaultsStrategiesV2 { access(self) var sink: {DeFiActions.Sink} access(self) var source: {DeFiActions.Source} - init(id: DeFiActions.UniqueIdentifier, collateralType: Type, position: @FlowALPv0.Position) { + /// @TODO on the next iteration store yieldToMoetSwapper in the resource + /// Swapper used to convert yield tokens back to MOET for debt repayment + //access(self) let yieldToMoetSwapper: {DeFiActions.Swapper} + + init( + id: DeFiActions.UniqueIdentifier, + collateralType: Type, + position: @FlowALPv0.Position + ) { self.uniqueID = id self.sink = position.createSink(type: collateralType) self.source = position.createSourceWithOptions(type: collateralType, pullFromTopUpSource: true) @@ -110,6 +118,64 @@ access(all) contract FlowYieldVaultsStrategiesV2 { } return <- self.source.withdrawAvailable(maxAmount: maxAmount) } + /// Closes the underlying FlowALP position by preparing repayment funds and closing with them. + /// + /// This method: + /// 1. Calculates debt amount from position + /// 2. Creates external yield token source from AutoBalancer + /// 3. Swaps yield tokens → MOET via stored swapper + /// 4. Closes position with prepared MOET vault + /// + /// This approach eliminates circular dependencies by preparing all funds externally + /// before calling the position's close method. + /// + access(FungibleToken.Withdraw) fun closePosition(collateralType: Type): @{FungibleToken.Vault} { + pre { + self.isSupportedCollateralType(collateralType): + "Unsupported collateral type \(collateralType.identifier)" + } + + // Step 1: Get debt amount from position using helper + let debtInfo = self.position.getTotalDebt() + let totalDebtAmount = debtInfo.amount + + // Step 2: If no debt, pass empty vault + if totalDebtAmount == 0.0 { + let emptyVault <- DeFiActionsUtils.getEmptyVault(Type<@MOET.Vault>()) + return <- self.position.closePosition( + repaymentVault: <-emptyVault, + collateralType: collateralType + ) + } + + // Step 3: Create external yield token source from AutoBalancer + let yieldTokenSource = FlowYieldVaultsAutoBalancers.createExternalSource(id: self.id()!) + ?? panic("Could not create external source from AutoBalancer") + + // Step 4: Retrieve yield→MOET swapper from contract config + let swapperKey = "yieldToMoetSwapper_".concat(self.id()!.toString()) + let yieldToMoetSwapper = FlowYieldVaultsStrategiesV2.config[swapperKey] as! {DeFiActions.Swapper}? + ?? panic("No yield→MOET swapper found for strategy \(self.id()!)") + + // Step 5: Use quoteIn to calculate exact yield token input needed for desired MOET output + // This bypasses SwapSource's branch selection issue where minimumAvailable + // underestimates due to RoundDown in quoteOut, causing insufficient output + // quoteIn rounds UP the input to guarantee exact output delivery + let quote = yieldToMoetSwapper.quoteIn(forDesired: totalDebtAmount, reverse: false) + + // Step 6: Withdraw the calculated yield token amount + let yieldTokenVault <- yieldTokenSource.withdrawAvailable(maxAmount: quote.inAmount) + + // Step 7: Swap with quote to get exact MOET output + // Swap honors the quote and delivers exactly totalDebtAmount + let moetVault <- yieldToMoetSwapper.swap(quote: quote, inVault: <-yieldTokenVault) + + // Step 8: Close position with prepared MOET vault + return <- self.position.closePosition( + repaymentVault: <-moetVault, + collateralType: collateralType + ) + } /// Executed when a Strategy is burned, cleaning up the Strategy's stored AutoBalancer access(contract) fun burnCallback() { FlowYieldVaultsAutoBalancers._cleanupAutoBalancer(id: self.id()!) @@ -304,6 +370,10 @@ access(all) contract FlowYieldVaultsStrategiesV2 { // Set AutoBalancer sink for overflow -> recollateralize balancerIO.autoBalancer.setSink(positionSwapSink, updateSinkID: true) + // Store yield→MOET swapper in contract config for later access during closePosition + let swapperKey = "yieldToMoetSwapper_".concat(uniqueID.id.toString()) + FlowYieldVaultsStrategiesV2.config[swapperKey] = yieldToMoetSwapper + switch type { case Type<@FUSDEVStrategy>(): return <-create FUSDEVStrategy( diff --git a/cadence/contracts/PMStrategiesV1.cdc b/cadence/contracts/PMStrategiesV1.cdc index 366e587..cd994d0 100644 --- a/cadence/contracts/PMStrategiesV1.cdc +++ b/cadence/contracts/PMStrategiesV1.cdc @@ -85,6 +85,16 @@ access(all) contract PMStrategiesV1 { } return <- self.source.withdrawAvailable(maxAmount: maxAmount) } + /// Closes the position by withdrawing all available collateral. + /// For simple strategies without FlowALP positions, this just withdraws all available balance. + access(FungibleToken.Withdraw) fun closePosition(collateralType: Type): @{FungibleToken.Vault} { + pre { + self.isSupportedCollateralType(collateralType): + "Unsupported collateral type \(collateralType.identifier)" + } + let availableBalance = self.availableBalance(ofToken: collateralType) + return <- self.withdraw(maxAmount: availableBalance, ofToken: collateralType) + } /// Executed when a Strategy is burned, cleaning up the Strategy's stored AutoBalancer access(contract) fun burnCallback() { FlowYieldVaultsAutoBalancers._cleanupAutoBalancer(id: self.id()!) @@ -150,6 +160,16 @@ access(all) contract PMStrategiesV1 { } return <- self.source.withdrawAvailable(maxAmount: maxAmount) } + /// Closes the position by withdrawing all available collateral. + /// For simple strategies without FlowALP positions, this just withdraws all available balance. + access(FungibleToken.Withdraw) fun closePosition(collateralType: Type): @{FungibleToken.Vault} { + pre { + self.isSupportedCollateralType(collateralType): + "Unsupported collateral type \(collateralType.identifier)" + } + let availableBalance = self.availableBalance(ofToken: collateralType) + return <- self.withdraw(maxAmount: availableBalance, ofToken: collateralType) + } /// Executed when a Strategy is burned, cleaning up the Strategy's stored AutoBalancer access(contract) fun burnCallback() { FlowYieldVaultsAutoBalancers._cleanupAutoBalancer(id: self.id()!) @@ -215,6 +235,16 @@ access(all) contract PMStrategiesV1 { } return <- self.source.withdrawAvailable(maxAmount: maxAmount) } + /// Closes the position by withdrawing all available collateral. + /// For simple strategies without FlowALP positions, this just withdraws all available balance. + access(FungibleToken.Withdraw) fun closePosition(collateralType: Type): @{FungibleToken.Vault} { + pre { + self.isSupportedCollateralType(collateralType): + "Unsupported collateral type \(collateralType.identifier)" + } + let availableBalance = self.availableBalance(ofToken: collateralType) + return <- self.withdraw(maxAmount: availableBalance, ofToken: collateralType) + } /// Executed when a Strategy is burned, cleaning up the Strategy's stored AutoBalancer access(contract) fun burnCallback() { FlowYieldVaultsAutoBalancers._cleanupAutoBalancer(id: self.id()!) diff --git a/cadence/contracts/mocks/MockStrategies.cdc b/cadence/contracts/mocks/MockStrategies.cdc index 84fbdcc..e753866 100644 --- a/cadence/contracts/mocks/MockStrategies.cdc +++ b/cadence/contracts/mocks/MockStrategies.cdc @@ -82,6 +82,66 @@ access(all) contract MockStrategies { } return <- self.source.withdrawAvailable(maxAmount: maxAmount) } + /// Closes the underlying FlowALP position by preparing repayment funds and closing with them. + /// + /// This method: + /// 1. Calculates debt amount from position + /// 2. Withdraws YT from AutoBalancer + /// 3. Swaps YT → MOET via external swapper + /// 4. Closes position with prepared MOET vault + /// + /// This approach eliminates circular dependencies by preparing all funds externally + /// before calling the position's close method. + /// + access(FungibleToken.Withdraw) fun closePosition(collateralType: Type): @{FungibleToken.Vault} { + pre { + self.isSupportedCollateralType(collateralType): + "Unsupported collateral type \(collateralType.identifier)" + } + + // Step 1: Get debt amount from position using helper + let debtInfo = self.position.getTotalDebt() + let totalDebtAmount = debtInfo.amount + + // Step 2: If no debt, pass empty vault + if totalDebtAmount == 0.0 { + let emptyVault <- DeFiActionsUtils.getEmptyVault(Type<@MOET.Vault>()) + return <- self.position.closePosition( + repaymentVault: <-emptyVault, + collateralType: collateralType + ) + } + + // Step 3: Create external YT source from AutoBalancer + let ytSource = FlowYieldVaultsAutoBalancers.createExternalSource(id: self.id()!) + ?? panic("Could not create external source from AutoBalancer") + + // Step 4: Create YT→MOET swapper + let ytToMoetSwapper = MockSwapper.Swapper( + inVault: Type<@YieldToken.Vault>(), + outVault: Type<@MOET.Vault>(), + uniqueID: self.copyID()! + ) + + // Step 5: Use quoteIn to calculate exact YT input needed for desired MOET output + // This bypasses SwapSource's branch selection issue where minimumAvailable + // underestimates due to RoundDown in quoteOut, causing insufficient output + // quoteIn rounds UP the input to guarantee exact output delivery + let quote = ytToMoetSwapper.quoteIn(forDesired: totalDebtAmount, reverse: false) + + // Step 6: Withdraw the calculated YT amount + let ytVault <- ytSource.withdrawAvailable(maxAmount: quote.inAmount) + + // Step 7: Swap with quote to get exact MOET output + // Swap honors the quote and delivers exactly totalDebtAmount + let moetVault <- ytToMoetSwapper.swap(quote: quote, inVault: <-ytVault) + + // Step 8: Close position with prepared MOET vault + return <- self.position.closePosition( + repaymentVault: <-moetVault, + collateralType: collateralType + ) + } /// Executed when a Strategy is burned, cleaning up the Strategy's stored AutoBalancer access(contract) fun burnCallback() { FlowYieldVaultsAutoBalancers._cleanupAutoBalancer(id: self.id()!) diff --git a/cadence/contracts/mocks/MockStrategy.cdc b/cadence/contracts/mocks/MockStrategy.cdc index 267055e..a60dfab 100644 --- a/cadence/contracts/mocks/MockStrategy.cdc +++ b/cadence/contracts/mocks/MockStrategy.cdc @@ -111,6 +111,17 @@ access(all) contract MockStrategy { return <- self.source.withdrawAvailable(maxAmount: maxAmount) } + /// Closes the position by withdrawing all available collateral. + /// For simple mock strategies without FlowALP positions, this just withdraws all available balance. + access(FungibleToken.Withdraw) fun closePosition(collateralType: Type): @{FungibleToken.Vault} { + pre { + self.isSupportedCollateralType(collateralType): + "Unsupported collateral type \(collateralType.identifier)" + } + let availableBalance = self.availableBalance(ofToken: collateralType) + return <- self.withdraw(maxAmount: availableBalance, ofToken: collateralType) + } + access(contract) fun burnCallback() {} // no-op access(all) fun getComponentInfo(): DeFiActions.ComponentInfo { diff --git a/cadence/contracts/mocks/MockSwapper.cdc b/cadence/contracts/mocks/MockSwapper.cdc index 99ed06d..e7861a4 100644 --- a/cadence/contracts/mocks/MockSwapper.cdc +++ b/cadence/contracts/mocks/MockSwapper.cdc @@ -75,7 +75,7 @@ access(all) contract MockSwapper { /// NOTE: This mock sources pricing data from the mocked oracle, allowing for pricing to be manually manipulated /// for testing and demonstration purposes access(all) fun swap(quote: {DeFiActions.Quote}?, inVault: @{FungibleToken.Vault}): @{FungibleToken.Vault} { - return <- self._swap(<-inVault, reverse: false) + return <- self._swap(quote: quote, from: <-inVault, reverse: false) } /// Performs a swap taking a Vault of type outVault, outputting a resulting inVault. Implementations may choose @@ -84,7 +84,7 @@ access(all) contract MockSwapper { /// NOTE: This mock sources pricing data from the mocked oracle, allowing for pricing to be manually manipulated /// for testing and demonstration purposes access(all) fun swapBack(quote: {DeFiActions.Quote}?, residual: @{FungibleToken.Vault}): @{FungibleToken.Vault} { - return <- self._swap(<-residual, reverse: true) + return <- self._swap(quote: quote, from: <-residual, reverse: true) } /// Internal estimator returning a quote for the amount in/out and in the desired direction @@ -114,8 +114,17 @@ access(all) contract MockSwapper { let uintInAmount = out ? uintAmount : (uintAmount / uintPrice) let uintOutAmount = out ? uintAmount * uintPrice : uintAmount - let inAmount = FlowALPMath.toUFix64Round(uintInAmount) - let outAmount = FlowALPMath.toUFix64Round(uintOutAmount) + // Round conservatively based on what's being calculated: + // - quoteOut (out=true): Use banker's rounding for balance - quotes are estimates used for + // availability checks and shouldn't systematically underestimate (which causes wrong branch selection) + // - quoteIn (out=false): Round UP the calculated input to ensure we can deliver the desired output + // The provided/desired amounts stay as-is without additional rounding + let inAmount = out + ? amount // provided input, use as-is + : FlowALPMath.toUFix64RoundUp(uintInAmount) // calculated input, round up + let outAmount = out + ? FlowALPMath.toUFix64RoundDown(uintOutAmount) // calculated output, banker's rounding for balanced estimates + : amount // desired output, use as-is (caller specifies exactly what they want) return SwapConnectors.BasicQuote( inType: reverse ? self.outVault : self.inVault, @@ -125,13 +134,16 @@ access(all) contract MockSwapper { ) } - access(self) fun _swap(_ from: @{FungibleToken.Vault}, reverse: Bool): @{FungibleToken.Vault} { + access(self) fun _swap(quote: {DeFiActions.Quote}?, from: @{FungibleToken.Vault}, reverse: Bool): @{FungibleToken.Vault} { let inAmount = from.balance var swapInVault = reverse ? MockSwapper.liquidityConnectors[from.getType()]! : MockSwapper.liquidityConnectors[self.inType()]! var swapOutVault = reverse ? MockSwapper.liquidityConnectors[self.inType()]! : MockSwapper.liquidityConnectors[self.outType()]! - swapInVault.depositCapacity(from: &from as auth(FungibleToken.Withdraw) &{FungibleToken.Vault}) + swapInVault.depositCapacity(from: &from as auth(FungibleToken.Withdraw) &{FungibleToken.Vault}) Burner.burn(<-from) - let outAmount = self.quoteOut(forProvided: inAmount, reverse: reverse).outAmount + + // Use the provided quote's outAmount when available to honor quoteIn's guarantee + // quoteIn rounds UP the input to ensure we can deliver the promised output + let outAmount = quote?.outAmount ?? self.quoteOut(forProvided: inAmount, reverse: reverse).outAmount var outVault <- swapOutVault.withdrawAvailable(maxAmount: outAmount) assert(outVault.balance == outAmount, diff --git a/cadence/tests/rebalance_scenario4_test.cdc b/cadence/tests/rebalance_scenario4_test.cdc new file mode 100644 index 0000000..b9f6319 --- /dev/null +++ b/cadence/tests/rebalance_scenario4_test.cdc @@ -0,0 +1,173 @@ +import Test +import BlockchainHelpers + +import "test_helpers.cdc" + +import "FlowToken" +import "MOET" +import "YieldToken" +import "MockStrategies" +import "FlowALPv0" + +access(all) let protocolAccount = Test.getAccount(0x0000000000000008) +access(all) let flowYieldVaultsAccount = Test.getAccount(0x0000000000000009) +access(all) let yieldTokenAccount = Test.getAccount(0x0000000000000010) + +access(all) var strategyIdentifier = Type<@MockStrategies.TracerStrategy>().identifier +access(all) var flowTokenIdentifier = Type<@FlowToken.Vault>().identifier +access(all) var yieldTokenIdentifier = Type<@YieldToken.Vault>().identifier +access(all) var moetTokenIdentifier = Type<@MOET.Vault>().identifier + +access(all) var snapshot: UInt64 = 0 + +// Helper function to get Flow collateral from position +access(all) fun getFlowCollateralFromPosition(pid: UInt64): UFix64 { + let positionDetails = getPositionDetails(pid: pid, beFailed: false) + for balance in positionDetails.balances { + if balance.vaultType == Type<@FlowToken.Vault>() { + // Credit means it's a deposit (collateral) + if balance.direction == FlowALPv0.BalanceDirection.Credit { + return balance.balance + } + } + } + return 0.0 +} + +// Helper function to get MOET debt from position +access(all) fun getMOETDebtFromPosition(pid: UInt64): UFix64 { + let positionDetails = getPositionDetails(pid: pid, beFailed: false) + for balance in positionDetails.balances { + if balance.vaultType == Type<@MOET.Vault>() { + // Debit means it's borrowed (debt) + if balance.direction == FlowALPv0.BalanceDirection.Debit { + return balance.balance + } + } + } + return 0.0 +} + +access(all) +fun setup() { + deployContracts() + + // set mocked token prices + setMockOraclePrice(signer: flowYieldVaultsAccount, forTokenIdentifier: yieldTokenIdentifier, price: 1000.0) + setMockOraclePrice(signer: flowYieldVaultsAccount, forTokenIdentifier: flowTokenIdentifier, price: 0.03) + + // mint tokens & set liquidity in mock swapper contract + let reserveAmount = 100_000_000.0 + setupMoetVault(protocolAccount, beFailed: false) + setupYieldVault(protocolAccount, beFailed: false) + mintFlow(to: protocolAccount, amount: reserveAmount) + mintMoet(signer: protocolAccount, to: protocolAccount.address, amount: reserveAmount, beFailed: false) + mintYield(signer: yieldTokenAccount, to: protocolAccount.address, amount: reserveAmount, beFailed: false) + setMockSwapperLiquidityConnector(signer: protocolAccount, vaultStoragePath: MOET.VaultStoragePath) + setMockSwapperLiquidityConnector(signer: protocolAccount, vaultStoragePath: YieldToken.VaultStoragePath) + setMockSwapperLiquidityConnector(signer: protocolAccount, vaultStoragePath: /storage/flowTokenVault) + + // setup FlowALP with a Pool & add FLOW as supported token + createAndStorePool(signer: protocolAccount, defaultTokenIdentifier: moetTokenIdentifier, beFailed: false) + addSupportedTokenFixedRateInterestCurve( + signer: protocolAccount, + tokenTypeIdentifier: flowTokenIdentifier, + collateralFactor: 0.8, + borrowFactor: 1.0, + yearlyRate: UFix128(0.1), + depositRate: 1_000_000.0, + depositCapacityCap: 1_000_000.0 + ) + + // open wrapped position (pushToDrawDownSink) + // the equivalent of depositing reserves + let openRes = executeTransaction( + "../../lib/FlowALP/cadence/transactions/flow-alp/position/create_position.cdc", + [reserveAmount/2.0, /storage/flowTokenVault, true], + protocolAccount + ) + Test.expect(openRes, Test.beSucceeded()) + + // enable mocked Strategy creation + addStrategyComposer( + signer: flowYieldVaultsAccount, + strategyIdentifier: strategyIdentifier, + composerIdentifier: Type<@MockStrategies.TracerStrategyComposer>().identifier, + issuerStoragePath: MockStrategies.IssuerStoragePath, + beFailed: false + ) + + // Fund FlowYieldVaults account for scheduling fees (atomic initial scheduling) + mintFlow(to: flowYieldVaultsAccount, amount: 100.0) + + snapshot = getCurrentBlockHeight() +} + +access(all) +fun test_RebalanceYieldVaultScenario4() { + // Scenario: large FLOW position at real-world low FLOW price + // FLOW drops further while YT price surges — tests closeYieldVault at extreme price ratios + let fundingAmount = 1000000.0 + let flowPriceDecrease = 0.02 // FLOW: $0.03 (setup) → $0.02 + let yieldPriceIncrease = 1500.0 // YT: $1000.0 (setup) → $1500.0 + + let user = Test.createAccount() + mintFlow(to: user, amount: fundingAmount) + grantBeta(flowYieldVaultsAccount, user) + + createYieldVault( + signer: user, + strategyIdentifier: strategyIdentifier, + vaultIdentifier: flowTokenIdentifier, + amount: fundingAmount, + beFailed: false + ) + + var yieldVaultIDs = getYieldVaultIDs(address: user.address) + var pid = 1 as UInt64 + Test.assert(yieldVaultIDs != nil, message: "Expected user's YieldVault IDs to be non-nil but encountered nil") + Test.assertEqual(1, yieldVaultIDs!.length) + log("[Scenario4] YieldVault ID: \(yieldVaultIDs![0]), position ID: \(pid)") + + // --- Phase 1: FLOW price drops from $0.03 to $0.02 --- + setMockOraclePrice(signer: flowYieldVaultsAccount, forTokenIdentifier: flowTokenIdentifier, price: flowPriceDecrease) + + let ytBefore = getAutoBalancerBalance(id: yieldVaultIDs![0])! + let debtBefore = getMOETDebtFromPosition(pid: pid) + let collateralBefore = getFlowCollateralFromPosition(pid: pid) + + log("\n[Scenario4] Pre-rebalance state (vault created @ FLOW=$0.03, YT=$1000.0; FLOW oracle now $\(flowPriceDecrease))") + log(" YT balance: \(ytBefore) YT") + log(" FLOW collateral: \(collateralBefore) FLOW (value: \(collateralBefore * flowPriceDecrease) MOET @ $\(flowPriceDecrease)/FLOW)") + log(" MOET debt: \(debtBefore) MOET") + + rebalanceYieldVault(signer: flowYieldVaultsAccount, id: yieldVaultIDs![0], force: true, beFailed: false) + rebalancePosition(signer: protocolAccount, pid: pid, force: true, beFailed: false) + + let ytAfterFlowDrop = getAutoBalancerBalance(id: yieldVaultIDs![0])! + let debtAfterFlowDrop = getMOETDebtFromPosition(pid: pid) + let collateralAfterFlowDrop = getFlowCollateralFromPosition(pid: pid) + + log("\n[Scenario4] After rebalance (FLOW=$\(flowPriceDecrease), YT=$1000.0)") + log(" YT balance: \(ytAfterFlowDrop) YT") + log(" FLOW collateral: \(collateralAfterFlowDrop) FLOW (value: \(collateralAfterFlowDrop * flowPriceDecrease) MOET)") + log(" MOET debt: \(debtAfterFlowDrop) MOET") + + // --- Phase 2: YT price rises from $1000.0 to $1500.0 --- + setMockOraclePrice(signer: flowYieldVaultsAccount, forTokenIdentifier: yieldTokenIdentifier, price: yieldPriceIncrease) + + rebalanceYieldVault(signer: flowYieldVaultsAccount, id: yieldVaultIDs![0], force: true, beFailed: false) + + let ytAfterYTRise = getAutoBalancerBalance(id: yieldVaultIDs![0])! + let debtAfterYTRise = getMOETDebtFromPosition(pid: pid) + let collateralAfterYTRise = getFlowCollateralFromPosition(pid: pid) + + log("\n[Scenario4] After rebalance (FLOW=$\(flowPriceDecrease), YT=$\(yieldPriceIncrease))") + log(" YT balance: \(ytAfterYTRise) YT") + log(" FLOW collateral: \(collateralAfterYTRise) FLOW (value: \(collateralAfterYTRise * flowPriceDecrease) MOET)") + log(" MOET debt: \(debtAfterYTRise) MOET") + + closeYieldVault(signer: user, id: yieldVaultIDs![0], beFailed: false) + + log("\n[Scenario4] Test complete") +} diff --git a/cadence/tests/rebalance_scenario5_test.cdc b/cadence/tests/rebalance_scenario5_test.cdc new file mode 100644 index 0000000..326a7e4 --- /dev/null +++ b/cadence/tests/rebalance_scenario5_test.cdc @@ -0,0 +1,222 @@ +import Test +import BlockchainHelpers + +import "test_helpers.cdc" + +import "FlowToken" +import "MOET" +import "YieldToken" +import "MockStrategies" +import "FlowALPv0" + +access(all) let protocolAccount = Test.getAccount(0x0000000000000008) +access(all) let flowYieldVaultsAccount = Test.getAccount(0x0000000000000009) +access(all) let yieldTokenAccount = Test.getAccount(0x0000000000000010) + +access(all) var strategyIdentifier = Type<@MockStrategies.TracerStrategy>().identifier +access(all) var collateralTokenIdentifier = Type<@FlowToken.Vault>().identifier +access(all) var yieldTokenIdentifier = Type<@YieldToken.Vault>().identifier +access(all) var moetTokenIdentifier = Type<@MOET.Vault>().identifier + +access(all) var snapshot: UInt64 = 0 + +// Helper function to get Flow collateral from position +access(all) fun getFlowCollateralFromPosition(pid: UInt64): UFix64 { + let positionDetails = getPositionDetails(pid: pid, beFailed: false) + for balance in positionDetails.balances { + if balance.vaultType == Type<@FlowToken.Vault>() { + // Credit means it's a deposit (collateral) + if balance.direction == FlowALPv0.BalanceDirection.Credit { + return balance.balance + } + } + } + return 0.0 +} + +// Helper function to get MOET debt from position +access(all) fun getMOETDebtFromPosition(pid: UInt64): UFix64 { + let positionDetails = getPositionDetails(pid: pid, beFailed: false) + for balance in positionDetails.balances { + if balance.vaultType == Type<@MOET.Vault>() { + // Debit means it's borrowed (debt) + if balance.direction == FlowALPv0.BalanceDirection.Debit { + return balance.balance + } + } + } + return 0.0 +} + +access(all) +fun setup() { + deployContracts() + + // set mocked token prices + setMockOraclePrice(signer: flowYieldVaultsAccount, forTokenIdentifier: yieldTokenIdentifier, price: 1.0) + setMockOraclePrice(signer: flowYieldVaultsAccount, forTokenIdentifier: collateralTokenIdentifier, price: 1000.00) + + // mint tokens & set liquidity in mock swapper contract + let reserveAmount = 100_000_00.0 + setupMoetVault(protocolAccount, beFailed: false) + setupYieldVault(protocolAccount, beFailed: false) + mintFlow(to: protocolAccount, amount: reserveAmount) + mintMoet(signer: protocolAccount, to: protocolAccount.address, amount: reserveAmount, beFailed: false) + mintYield(signer: yieldTokenAccount, to: protocolAccount.address, amount: reserveAmount, beFailed: false) + setMockSwapperLiquidityConnector(signer: protocolAccount, vaultStoragePath: MOET.VaultStoragePath) + setMockSwapperLiquidityConnector(signer: protocolAccount, vaultStoragePath: YieldToken.VaultStoragePath) + setMockSwapperLiquidityConnector(signer: protocolAccount, vaultStoragePath: /storage/flowTokenVault) + + // setup FlowALP with a Pool & add FLOW as supported token + createAndStorePool(signer: protocolAccount, defaultTokenIdentifier: moetTokenIdentifier, beFailed: false) + addSupportedTokenFixedRateInterestCurve( + signer: protocolAccount, + tokenTypeIdentifier: collateralTokenIdentifier, + collateralFactor: 0.8, + borrowFactor: 1.0, + yearlyRate: UFix128(0.1), + depositRate: 1_000_000.0, + depositCapacityCap: 1_000_000.0 + ) + + // open wrapped position (pushToDrawDownSink) + // the equivalent of depositing reserves + let openRes = executeTransaction( + "../../lib/FlowALP/cadence/transactions/flow-alp/position/create_position.cdc", + [reserveAmount/2.0, /storage/flowTokenVault, true], + protocolAccount + ) + Test.expect(openRes, Test.beSucceeded()) + + // enable mocked Strategy creation + addStrategyComposer( + signer: flowYieldVaultsAccount, + strategyIdentifier: strategyIdentifier, + composerIdentifier: Type<@MockStrategies.TracerStrategyComposer>().identifier, + issuerStoragePath: MockStrategies.IssuerStoragePath, + beFailed: false + ) + + // Fund FlowYieldVaults account for scheduling fees (atomic initial scheduling) + mintFlow(to: flowYieldVaultsAccount, amount: 100.0) + + snapshot = getCurrentBlockHeight() +} + +access(all) +fun test_RebalanceYieldVaultScenario5() { + // Scenario 5: High-value collateral with moderate price drop + // Tests rebalancing when FLOW drops 20% from $1000 → $800 + // This scenario tests whether position can handle moderate drops without liquidation + + let fundingAmount = 100.0 + let initialFlowPrice = 1000.00 // Setup price + let flowPriceDecrease = 800.00 // FLOW: $1000 → $800 (20% drop) + let yieldPriceIncrease = 1.5 // YT: $1.0 → $1.5 + + let user = Test.createAccount() + mintFlow(to: user, amount: fundingAmount) + grantBeta(flowYieldVaultsAccount, user) + + createYieldVault( + signer: user, + strategyIdentifier: strategyIdentifier, + vaultIdentifier: collateralTokenIdentifier, + amount: fundingAmount, + beFailed: false + ) + + var yieldVaultIDs = getYieldVaultIDs(address: user.address) + var pid = 1 as UInt64 + Test.assert(yieldVaultIDs != nil, message: "Expected user's YieldVault IDs to be non-nil but encountered nil") + Test.assertEqual(1, yieldVaultIDs!.length) + log("[Scenario5] YieldVault ID: \(yieldVaultIDs![0]), position ID: \(pid)") + + // Calculate initial health + let initialCollateralValue = fundingAmount * initialFlowPrice + let initialDebt = initialCollateralValue * 0.8 / 1.1 // CF=0.8, minHealth=1.1 + let initialHealth = (fundingAmount * 0.8 * initialFlowPrice) / initialDebt + log("[Scenario5] Initial state (FLOW=$\(initialFlowPrice), YT=$1.0)") + log(" Funding: \(fundingAmount) FLOW") + log(" Collateral value: $\(initialCollateralValue)") + log(" Expected debt: $\(initialDebt) MOET") + log(" Initial health: \(initialHealth)") + + // --- Phase 1: FLOW price drops from $1000 to $800 (20% drop) --- + setMockOraclePrice(signer: flowYieldVaultsAccount, forTokenIdentifier: collateralTokenIdentifier, price: flowPriceDecrease) + + let ytBefore = getAutoBalancerBalance(id: yieldVaultIDs![0])! + let debtBefore = getMOETDebtFromPosition(pid: pid) + let collateralBefore = getFlowCollateralFromPosition(pid: pid) + + // Calculate health before rebalance (avoid division by zero) + let healthBeforeRebalance = debtBefore > 0.0 + ? (collateralBefore * 0.8 * flowPriceDecrease) / debtBefore + : 0.0 + let collateralValueBefore = collateralBefore * flowPriceDecrease + + log("[Scenario5] After price drop to $\(flowPriceDecrease) (BEFORE rebalance)") + log(" YT balance: \(ytBefore) YT") + log(" FLOW collateral: \(collateralBefore) FLOW") + log(" Collateral value: $\(collateralValueBefore) MOET") + log(" MOET debt: \(debtBefore) MOET") + log(" Health: \(healthBeforeRebalance)") + + if healthBeforeRebalance < 1.0 { + log(" ⚠️ WARNING: Health dropped below 1.0! Position is at liquidation risk!") + log(" ⚠️ Health = (100 FLOW × 0.8 × $800) / $72,727 = $64,000 / $72,727 = \(healthBeforeRebalance)") + log(" ⚠️ A 20% price drop causes ~20% health drop from 1.1 → \(healthBeforeRebalance)") + } + + // Rebalance to restore health to targetHealth (1.3) + log("[Scenario5] Rebalancing position and yield vault...") + rebalanceYieldVault(signer: flowYieldVaultsAccount, id: yieldVaultIDs![0], force: true, beFailed: false) + rebalancePosition(signer: protocolAccount, pid: pid, force: true, beFailed: false) + + let ytAfterFlowDrop = getAutoBalancerBalance(id: yieldVaultIDs![0])! + let debtAfterFlowDrop = getMOETDebtFromPosition(pid: pid) + let collateralAfterFlowDrop = getFlowCollateralFromPosition(pid: pid) + let healthAfterRebalance = debtAfterFlowDrop > 0.0 + ? (collateralAfterFlowDrop * 0.8 * flowPriceDecrease) / debtAfterFlowDrop + : 0.0 + + log("[Scenario5] After rebalance (FLOW=$\(flowPriceDecrease), YT=$1.0)") + log(" YT balance: \(ytAfterFlowDrop) YT") + log(" FLOW collateral: \(collateralAfterFlowDrop) FLOW") + log(" Collateral value: $\(collateralAfterFlowDrop * flowPriceDecrease) MOET") + log(" MOET debt: \(debtAfterFlowDrop) MOET") + log(" Health: \(healthAfterRebalance)") + + if healthAfterRebalance >= 1.3 { + log(" ✅ Health restored to targetHealth (1.3)") + } else if healthAfterRebalance >= 1.1 { + log(" ✅ Health above minHealth (1.1) but below targetHealth (1.3)") + } else { + log(" ❌ Health still below minHealth!") + } + + // --- Phase 2: YT price rises from $1.0 to $1.5 --- + log("[Scenario5] Phase 2: YT price increases to $\(yieldPriceIncrease)") + setMockOraclePrice(signer: flowYieldVaultsAccount, forTokenIdentifier: yieldTokenIdentifier, price: yieldPriceIncrease) + + rebalanceYieldVault(signer: flowYieldVaultsAccount, id: yieldVaultIDs![0], force: true, beFailed: false) + + let ytAfterYTRise = getAutoBalancerBalance(id: yieldVaultIDs![0])! + let debtAfterYTRise = getMOETDebtFromPosition(pid: pid) + let collateralAfterYTRise = getFlowCollateralFromPosition(pid: pid) + let healthAfterYTRise = debtAfterYTRise > 0.0 + ? (collateralAfterYTRise * 0.8 * flowPriceDecrease) / debtAfterYTRise + : 0.0 + + log("[Scenario5] After YT rise (FLOW=$\(flowPriceDecrease), YT=$\(yieldPriceIncrease))") + log(" YT balance: \(ytAfterYTRise) YT") + log(" FLOW collateral: \(collateralAfterYTRise) FLOW") + log(" Collateral value: $\(collateralAfterYTRise * flowPriceDecrease) MOET") + log(" MOET debt: \(debtAfterYTRise) MOET") + log(" Health: \(healthAfterYTRise)") + + // Try to close - EXPECT IT TO FAIL due to precision residual + log("\n[Scenario5] Attempting to close yield vault...") + + closeYieldVault(signer: user, id: yieldVaultIDs![0], beFailed: false) +} diff --git a/cadence/tests/rebalance_yield_test.cdc b/cadence/tests/rebalance_yield_test.cdc index bbe9cce..aaac6fd 100644 --- a/cadence/tests/rebalance_yield_test.cdc +++ b/cadence/tests/rebalance_yield_test.cdc @@ -135,7 +135,7 @@ fun test_RebalanceYieldVaultScenario2() { log("[TEST] YieldVault balance after yield before \(yieldTokenPrice) rebalance: \(yieldVaultBalance ?? 0.0)") Test.assert( - yieldVaultBalance == expectedFlowBalance[index], + equalAmounts(a: yieldVaultBalance!, b: expectedFlowBalance[index], tolerance: 0.01), message: "YieldVault balance of \(yieldVaultBalance ?? 0.0) doesn't match an expected value \(expectedFlowBalance[index])" ) } diff --git a/lib/FlowALP b/lib/FlowALP index d9970e3..ca37d21 160000 --- a/lib/FlowALP +++ b/lib/FlowALP @@ -1 +1 @@ -Subproject commit d9970e3d7aedffcb15eb1f953b299173c137f718 +Subproject commit ca37d21b2fc6992e065c2d6e777445b01f0007a5