diff --git a/cadence/contracts/FlowYieldVaultsStrategiesV2.cdc b/cadence/contracts/FlowYieldVaultsStrategiesV2.cdc index 99b6ea34..0928b99f 100644 --- a/cadence/contracts/FlowYieldVaultsStrategiesV2.cdc +++ b/cadence/contracts/FlowYieldVaultsStrategiesV2.cdc @@ -1,4 +1,5 @@ // standards +import "Burner" import "FungibleToken" import "EVM" // DeFiActions @@ -42,11 +43,96 @@ access(all) contract FlowYieldVaultsStrategiesV2 { access(all) let univ3RouterEVMAddress: EVM.EVMAddress access(all) let univ3QuoterEVMAddress: EVM.EVMAddress + /// Partitioned config map. Each key is a partition name; each value is a typed nested map keyed by + /// strategy UniqueIdentifier ID (UInt64). Current partitions: + /// "closedPositions" → {UInt64: Bool} access(contract) let config: {String: AnyStruct} /// Canonical StoragePath where the StrategyComposerIssuer should be stored access(all) let IssuerStoragePath: StoragePath + /// Emitted when a non-empty vault is destroyed because the swapper quote returned zero output, + /// indicating the balance is too small to route (dust). Includes the quote as evidence of why + /// the burn decision was made, to aid debugging of stale or misconfigured swapper paths. + access(all) event DustBurned( + tokenType: String, + balance: UFix64, + quoteInType: String, + quoteOutType: String, + quoteInAmount: UFix64, + quoteOutAmount: UFix64, + swapperType: String + ) + + /// A Source that converts yield tokens to debt tokens by pulling ALL available yield + /// tokens from the wrapped source, rather than using quoteIn to limit the pull amount. + /// + /// This avoids ERC4626 rounding issues where quoteIn might underestimate required shares, + /// causing the swap to return less than the requested debt amount. By pulling everything + /// and swapping everything, the output is as large as the yield position allows. + /// + /// The caller is responsible for ensuring the yield tokens (after swapping) will cover the + /// required debt — e.g. by pre-depositing supplemental MOET to reduce the position's debt + /// before calling closePosition (see FUSDEVStrategy.closePosition step 6). + access(all) struct BufferedSwapSource : DeFiActions.Source { + access(self) let swapper: {DeFiActions.Swapper} + access(self) let source: {DeFiActions.Source} + access(contract) var uniqueID: DeFiActions.UniqueIdentifier? + + init( + swapper: {DeFiActions.Swapper}, + source: {DeFiActions.Source}, + uniqueID: DeFiActions.UniqueIdentifier? + ) { + pre { + source.getSourceType() == swapper.inType(): + "source type != swapper inType" + } + self.swapper = swapper + self.source = source + self.uniqueID = uniqueID + } + + access(all) fun getComponentInfo(): DeFiActions.ComponentInfo { + return DeFiActions.ComponentInfo( + type: self.getType(), + id: self.id(), + innerComponents: [ + self.swapper.getComponentInfo(), + self.source.getComponentInfo() + ] + ) + } + access(contract) view fun copyID(): DeFiActions.UniqueIdentifier? { return self.uniqueID } + access(contract) fun setID(_ id: DeFiActions.UniqueIdentifier?) { self.uniqueID = id } + access(all) view fun getSourceType(): Type { return self.swapper.outType() } + access(all) fun minimumAvailable(): UFix64 { + let avail = self.source.minimumAvailable() + if avail == 0.0 { return 0.0 } + return self.swapper.quoteOut(forProvided: avail, reverse: false).outAmount + } + /// Pulls ALL available yield tokens from the source and swaps them to the debt token. + /// Ignores quoteIn — avoids ERC4626 rounding underestimates that would leave us short. + access(FungibleToken.Withdraw) fun withdrawAvailable(maxAmount: UFix64): @{FungibleToken.Vault} { + if maxAmount == 0.0 { + return <- DeFiActionsUtils.getEmptyVault(self.getSourceType()) + } + let availableIn = self.source.minimumAvailable() + if availableIn == 0.0 { + return <- DeFiActionsUtils.getEmptyVault(self.getSourceType()) + } + // Pull ALL available yield tokens (not quoteIn-limited) + let sourceLiquidity <- self.source.withdrawAvailable(maxAmount: availableIn) + if sourceLiquidity.balance == 0.0 { + Burner.burn(<-sourceLiquidity) + return <- DeFiActionsUtils.getEmptyVault(self.getSourceType()) + } + let swapped <- self.swapper.swap(quote: nil, inVault: <-sourceLiquidity) + assert(swapped.balance > 0.0, message: "BufferedSwapSource: swap returned zero despite available input") + return <- swapped + } + } + access(all) struct CollateralConfig { access(all) let yieldTokenEVMAddress: EVM.EVMAddress access(all) let yieldToCollateralUniV3AddressPath: [EVM.EVMAddress] @@ -84,10 +170,6 @@ access(all) contract FlowYieldVaultsStrategiesV2 { access(self) let position: @FlowALPv0.Position access(self) var sink: {DeFiActions.Sink} access(self) var source: {DeFiActions.Source} - /// Tracks whether the underlying FlowALP position has been closed. Once true, - /// availableBalance() returns 0.0 to avoid panicking when the pool no longer - /// holds the position (e.g. during YieldVault burnCallback after close). - access(self) var positionClosed: Bool init( id: DeFiActions.UniqueIdentifier, @@ -97,7 +179,6 @@ access(all) contract FlowYieldVaultsStrategiesV2 { self.uniqueID = id self.sink = position.createSink(type: collateralType) self.source = position.createSourceWithOptions(type: collateralType, pullFromTopUpSource: true) - self.positionClosed = false self.position <-position } @@ -109,11 +190,10 @@ access(all) contract FlowYieldVaultsStrategiesV2 { } /// Returns the amount available for withdrawal via the inner Source access(all) fun availableBalance(ofToken: Type): UFix64 { - if self.positionClosed { return 0.0 } + if self._isPositionClosed() { return 0.0 } return ofToken == self.source.getSourceType() ? self.source.minimumAvailable() : 0.0 } /// Deposits up to the inner Sink's capacity from the provided authorized Vault reference. - /// Only the single configured collateral type is accepted — one collateral type per position. access(all) fun deposit(from: auth(FungibleToken.Withdraw) &{FungibleToken.Vault}) { pre { from.getType() == self.sink.getSinkType(): @@ -179,12 +259,12 @@ access(all) contract FlowYieldVaultsStrategiesV2 { // Zero vaults: dust collateral rounded down to zero — return an empty vault if resultVaults.length == 0 { destroy resultVaults - self.positionClosed = true + self._markPositionClosed() return <- DeFiActionsUtils.getEmptyVault(collateralType) } - let collateralVault <- resultVaults.removeFirst() + var collateralVault <- resultVaults.removeFirst() destroy resultVaults - self.positionClosed = true + self._markPositionClosed() return <- collateralVault } @@ -192,58 +272,122 @@ access(all) contract FlowYieldVaultsStrategiesV2 { let yieldTokenSource = FlowYieldVaultsAutoBalancers.createExternalSource(id: self.id()!) ?? panic("Could not create external source from AutoBalancer") - // Step 5: Retrieve yield→MOET swapper from contract config - let swapperKey = FlowYieldVaultsStrategiesV2.getYieldToMoetSwapperConfigKey(self.uniqueID)! - let yieldToMoetSwapper = FlowYieldVaultsStrategiesV2.config[swapperKey] as! {DeFiActions.Swapper}? - ?? panic("No yield→MOET swapper found for strategy \(self.id()!)") + // Step 5: Reconstruct yield→MOET swapper from stored CollateralConfig. + let closeCollateralConfig = self._getStoredCollateralConfig( + strategyType: Type<@FUSDEVStrategy>(), + collateralType: collateralType + ) ?? panic("No CollateralConfig for FUSDEVStrategy with \(collateralType.identifier)") + let closeTokens = FlowYieldVaultsStrategiesV2._resolveTokenBundle( + yieldTokenEVMAddress: closeCollateralConfig.yieldTokenEVMAddress + ) + let yieldToMoetSwapper = self._buildYieldToDebtSwapper( + tokens: closeTokens, + uniqueID: self.uniqueID! + ) + + // Step 6: Pre-supplement from collateral if yield is insufficient to cover the full debt. + // + // The FUSDEV close path has a structural ~0.02% round-trip fee loss: + // Open: MOET → PYUSD0 (UniV3 0.01%) → FUSDEV (ERC4626, free) + // Close: FUSDEV → PYUSD0 (ERC4626, free) → MOET (UniV3 0.01%) + // In production, accrued yield more than covers this; with no accrued yield (e.g. in + // tests, immediate open+close), the yield tokens convert back to slightly less MOET + // than was borrowed. We handle this by pre-pulling a tiny amount of collateral from + // self.source, swapping it to MOET, and depositing it into the position to reduce the + // outstanding debt — BEFORE calling position.closePosition. + // + // This MUST be done before closePosition because the position is locked during close: + // any attempt to pull from self.source inside a repaymentSource.withdrawAvailable call + // would trigger "Reentrancy: position X is locked". + let yieldAvail = yieldTokenSource.minimumAvailable() + let expectedMOET = yieldAvail > 0.0 + ? yieldToMoetSwapper.quoteOut(forProvided: yieldAvail, reverse: false).outAmount + : 0.0 + if expectedMOET < totalDebtAmount { + let collateralToMoetSwapper = self._buildCollateralToDebtSwapper( + collateralConfig: closeCollateralConfig, + tokens: closeTokens, + collateralType: collateralType, + uniqueID: self.uniqueID! + ) + let shortfall = totalDebtAmount - expectedMOET + // Over-deposit by 1% so the remaining debt lands below expectedMOET, giving + // BufferedSwapSource enough margin to cover ERC4626 floor-rounding at redemption + let buffered = shortfall + shortfall / 100.0 + let quote = collateralToMoetSwapper.quoteIn(forDesired: buffered, reverse: false) + assert(quote.inAmount > 0.0, + message: "Pre-supplement: collateral→MOET quote returned zero input for non-zero shortfall — swapper misconfigured") + let extraCollateral <- self.source.withdrawAvailable(maxAmount: quote.inAmount) + assert(extraCollateral.balance > 0.0, + message: "Pre-supplement: no collateral available to cover shortfall of \(shortfall) MOET") + let extraMOET <- collateralToMoetSwapper.swap(quote: quote, inVault: <-extraCollateral) + assert(extraMOET.balance > 0.0, + message: "Pre-supplement: collateral→MOET swap produced zero output") + self.position.deposit(from: <-extraMOET) + } - // Step 6: Create a SwapSource that converts yield tokens to MOET when pulled by closePosition. - // The pool will call source.withdrawAvailable(maxAmount: debtAmount) which internally uses - // quoteIn(forDesired: debtAmount) to compute the exact yield token input needed. - let moetSource = SwapConnectors.SwapSource( + // Step 7: Create a BufferedSwapSource that converts ALL yield tokens → MOET. + // Pulling all (not quoteIn-limited) avoids ERC4626 rounding underestimates. + // After the pre-supplement above, the remaining debt is covered by the yield tokens. + let moetSource = FlowYieldVaultsStrategiesV2.BufferedSwapSource( swapper: yieldToMoetSwapper, source: yieldTokenSource, uniqueID: self.copyID() ) - // Step 7: Close position - pool pulls exactly the debt amount from moetSource + // Step 8: Close position - pool pulls up to the (now pre-reduced) debt from moetSource let resultVaults <- self.position.closePosition(repaymentSources: [moetSource]) // With one collateral type and one debt type, the pool returns at most two vaults: // the collateral vault and optionally a MOET overpayment dust vault. - assert( - resultVaults.length >= 1 && resultVaults.length <= 2, - message: "Expected 1 or 2 vaults from closePosition, got \(resultVaults.length)" - ) - - var collateralVault <- resultVaults.removeFirst() - assert( - collateralVault.getType() == collateralType, - message: "First vault returned from closePosition must be collateral (\(collateralType.identifier)), got \(collateralVault.getType().identifier)" + // closePosition returns vaults in dict-iteration order (hash-based), so we cannot + // assume the collateral vault is first. Find it by type and convert any non-collateral + // vaults (MOET overpayment dust) back to collateral via reconstructed swapper. + // Reconstruct MOET→YIELD→collateral from CollateralConfig. + let debtToCollateralSwapper = self._buildDebtToCollateralSwapper( + collateralConfig: closeCollateralConfig, + tokens: closeTokens, + collateralType: collateralType, + uniqueID: self.uniqueID! ) - // Handle any overpayment dust (MOET) returned as the second vault + var collateralVault <- DeFiActionsUtils.getEmptyVault(collateralType) while resultVaults.length > 0 { - let dustVault <- resultVaults.removeFirst() - if dustVault.balance > 0.0 { - if dustVault.getType() == collateralType { - collateralVault.deposit(from: <-dustVault) + let v <- resultVaults.removeFirst() + if v.getType() == collateralType { + collateralVault.deposit(from: <-v) + } else if v.balance == 0.0 { + // destroy empty vault + Burner.burn(<-v) + } else { + // Quote first — if dust is too small to route, destroy it + let quote = debtToCollateralSwapper.quoteOut(forProvided: v.balance, reverse: false) + if quote.outAmount > 0.0 { + let swapped <- debtToCollateralSwapper.swap(quote: quote, inVault: <-v) + collateralVault.deposit(from: <-swapped) } else { - // @TODO implement swapping moet to collateral - destroy dustVault + emit DustBurned( + tokenType: v.getType().identifier, + balance: v.balance, + quoteInType: quote.inType.identifier, + quoteOutType: quote.outType.identifier, + quoteInAmount: quote.inAmount, + quoteOutAmount: quote.outAmount, + swapperType: debtToCollateralSwapper.getType().identifier + ) + Burner.burn(<-v) } - } else { - destroy dustVault } } destroy resultVaults - self.positionClosed = true + self._markPositionClosed() return <- collateralVault } /// Executed when a Strategy is burned, cleaning up the Strategy's stored AutoBalancer access(contract) fun burnCallback() { FlowYieldVaultsAutoBalancers._cleanupAutoBalancer(id: self.id()!) + self._cleanupPositionClosed() } access(all) fun getComponentInfo(): DeFiActions.ComponentInfo { return DeFiActions.ComponentInfo( @@ -261,9 +405,150 @@ access(all) contract FlowYieldVaultsStrategiesV2 { access(contract) fun setID(_ id: DeFiActions.UniqueIdentifier?) { self.uniqueID = id } + + /* =========================== + closePosition helpers + =========================== */ + + access(self) fun _getStoredCollateralConfig( + strategyType: Type, + collateralType: Type + ): CollateralConfig? { + let issuer = FlowYieldVaultsStrategiesV2.account.storage.borrow< + &FlowYieldVaultsStrategiesV2.StrategyComposerIssuer + >(from: FlowYieldVaultsStrategiesV2.IssuerStoragePath) + if issuer == nil { return nil } + return issuer!.getCollateralConfig(strategyType: strategyType, collateralType: collateralType) + } + + /// Builds a YIELD→MOET MultiSwapper (AMM direct + ERC4626 redeem path). + access(self) fun _buildYieldToDebtSwapper( + tokens: FlowYieldVaultsStrategiesV2.TokenBundle, + uniqueID: DeFiActions.UniqueIdentifier + ): SwapConnectors.MultiSwapper { + let yieldToDebtAMM = FlowYieldVaultsStrategiesV2._buildUniV3Swapper( + tokenPath: [tokens.yieldTokenEVMAddress, tokens.moetTokenEVMAddress], + feePath: [100], + inVault: tokens.yieldTokenType, + outVault: tokens.moetTokenType, + uniqueID: uniqueID + ) + let yieldToUnderlying = MorphoERC4626SwapConnectors.Swapper( + vaultEVMAddress: tokens.yieldTokenEVMAddress, + coa: FlowYieldVaultsStrategiesV2._getCOACapability(), + feeSource: FlowYieldVaultsStrategiesV2._createFeeSource(withID: uniqueID), + uniqueID: uniqueID, + isReversed: true + ) + let underlyingToDebt = FlowYieldVaultsStrategiesV2._buildUniV3Swapper( + tokenPath: [tokens.underlying4626AssetEVMAddress, tokens.moetTokenEVMAddress], + feePath: [100], + inVault: tokens.underlying4626AssetType, + outVault: tokens.moetTokenType, + uniqueID: uniqueID + ) + let seq = SwapConnectors.SequentialSwapper( + swappers: [yieldToUnderlying, underlyingToDebt], + uniqueID: uniqueID + ) + return SwapConnectors.MultiSwapper( + inVault: tokens.yieldTokenType, + outVault: tokens.moetTokenType, + swappers: [yieldToDebtAMM, seq], + uniqueID: uniqueID + ) + } + + /// Builds a collateral→MOET UniV3 swapper from CollateralConfig. + /// Derives the path by reversing yieldToCollateralUniV3AddressPath[1..] (skipping the + /// yield token) and appending MOET, preserving all intermediate hops. + /// e.g. [FUSDEV, PYUSD0, WETH, WBTC] → [WBTC, WETH, PYUSD0, MOET] + access(self) fun _buildCollateralToDebtSwapper( + collateralConfig: FlowYieldVaultsStrategiesV2.CollateralConfig, + tokens: FlowYieldVaultsStrategiesV2.TokenBundle, + collateralType: Type, + uniqueID: DeFiActions.UniqueIdentifier + ): UniswapV3SwapConnectors.Swapper { + let yieldToCollPath = collateralConfig.yieldToCollateralUniV3AddressPath + let yieldToCollFees = collateralConfig.yieldToCollateralUniV3FeePath + assert(yieldToCollPath.length >= 2, message: "yieldToCollateral path requires at least yield and collateral tokens, got \(yieldToCollPath.length)") + // Build reversed path: iterate yieldToCollPath from last down to index 1 (skip yield token at 0), + // then append MOET. e.g. [FUSDEV, PYUSD0, WETH, WBTC] → [WBTC, WETH, PYUSD0] + MOET + var collToDebtPath: [EVM.EVMAddress] = [] + var collToDebtFees: [UInt32] = [] + for i in InclusiveRange(yieldToCollPath.length - 1, 1, step: -1) { + collToDebtPath.append(yieldToCollPath[i]) + } + collToDebtPath.append(tokens.moetTokenEVMAddress) + // Build reversed fees: iterate from last down to index 1 (skip yield→underlying fee at 0), + // then append PYUSD0→MOET fee (100). e.g. [100, 3000, 3000] → [3000, 3000] + 100 + for i in InclusiveRange(yieldToCollFees.length - 1, 1, step: -1) { + collToDebtFees.append(yieldToCollFees[i]) + } + collToDebtFees.append(UInt32(100)) + return FlowYieldVaultsStrategiesV2._buildUniV3Swapper( + tokenPath: collToDebtPath, + feePath: collToDebtFees, + inVault: collateralType, + outVault: tokens.moetTokenType, + uniqueID: uniqueID + ) + } + + /// Builds a MOET→collateral SequentialSwapper for dust handling: MOET→YIELD→collateral. + access(self) fun _buildDebtToCollateralSwapper( + collateralConfig: FlowYieldVaultsStrategiesV2.CollateralConfig, + tokens: FlowYieldVaultsStrategiesV2.TokenBundle, + collateralType: Type, + uniqueID: DeFiActions.UniqueIdentifier + ): SwapConnectors.SequentialSwapper { + let debtToYieldAMM = FlowYieldVaultsStrategiesV2._buildUniV3Swapper( + tokenPath: [tokens.moetTokenEVMAddress, tokens.yieldTokenEVMAddress], + feePath: [100], + inVault: tokens.moetTokenType, + outVault: tokens.yieldTokenType, + uniqueID: uniqueID + ) + let yieldToCollateral = FlowYieldVaultsStrategiesV2._buildUniV3Swapper( + tokenPath: collateralConfig.yieldToCollateralUniV3AddressPath, + feePath: collateralConfig.yieldToCollateralUniV3FeePath, + inVault: tokens.yieldTokenType, + outVault: collateralType, + uniqueID: uniqueID + ) + return SwapConnectors.SequentialSwapper( + swappers: [debtToYieldAMM, yieldToCollateral], + uniqueID: uniqueID + ) + } + + access(self) view fun _isPositionClosed(): Bool { + if let id = self.uniqueID { + let partition = FlowYieldVaultsStrategiesV2.config["closedPositions"] as! {UInt64: Bool}? ?? {} + return partition[id.id] ?? false + } + return false + } + + access(self) fun _markPositionClosed() { + if let id = self.uniqueID { + var partition = FlowYieldVaultsStrategiesV2.config["closedPositions"] as! {UInt64: Bool}? ?? {} + partition[id.id] = true + FlowYieldVaultsStrategiesV2.config["closedPositions"] = partition + } + } + + access(self) fun _cleanupPositionClosed() { + if let id = self.uniqueID { + var partition = FlowYieldVaultsStrategiesV2.config["closedPositions"] as! {UInt64: Bool}? ?? {} + partition.remove(key: id.id) + FlowYieldVaultsStrategiesV2.config["closedPositions"] = partition + } + } } access(all) struct TokenBundle { + /// The MOET token type (the pool's borrowable token) access(all) let moetTokenType: Type access(all) let moetTokenEVMAddress: EVM.EVMAddress @@ -310,12 +595,116 @@ access(all) contract FlowYieldVaultsStrategiesV2 { } } - /// This StrategyComposer builds a Strategy that uses MorphoERC4626 vault + /* =========================== + Contract-level shared infrastructure + =========================== */ + + /// Gets the Pool's default token type (the borrowable token) + access(self) fun _getPoolDefaultToken(): Type { + let poolCap = FlowYieldVaultsStrategiesV2.account.storage.copy< + Capability + >(from: FlowALPv0.PoolCapStoragePath) + ?? panic("Missing or invalid pool capability") + let poolRef = poolCap.borrow() ?? panic("Invalid Pool Cap") + return poolRef.getDefaultToken() + } + + /// Resolves the full token bundle for a strategy given the ERC4626 yield vault address. + /// The MOET token is always the pool's default token. + access(self) fun _resolveTokenBundle(yieldTokenEVMAddress: EVM.EVMAddress): FlowYieldVaultsStrategiesV2.TokenBundle { + let moetTokenType = FlowYieldVaultsStrategiesV2._getPoolDefaultToken() + let moetTokenEVMAddress = FlowEVMBridgeConfig.getEVMAddressAssociated(with: moetTokenType) + ?? panic("Token Vault type \(moetTokenType.identifier) has not yet been registered with the VMbridge") + + let yieldTokenType = FlowEVMBridgeConfig.getTypeAssociated(with: yieldTokenEVMAddress) + ?? panic("Could not retrieve the VM Bridge associated Type for the yield token address \(yieldTokenEVMAddress.toString())") + + let underlying4626AssetEVMAddress = ERC4626Utils.underlyingAssetEVMAddress(vault: yieldTokenEVMAddress) + ?? panic("Could not get the underlying asset's EVM address for ERC4626Vault \(yieldTokenEVMAddress.toString())") + let underlying4626AssetType = FlowEVMBridgeConfig.getTypeAssociated(with: underlying4626AssetEVMAddress) + ?? panic("Could not retrieve the VM Bridge associated Type for the ERC4626 underlying asset \(underlying4626AssetEVMAddress.toString())") + + return FlowYieldVaultsStrategiesV2.TokenBundle( + moetTokenType: moetTokenType, + moetTokenEVMAddress: moetTokenEVMAddress, + yieldTokenType: yieldTokenType, + yieldTokenEVMAddress: yieldTokenEVMAddress, + underlying4626AssetType: underlying4626AssetType, + underlying4626AssetEVMAddress: underlying4626AssetEVMAddress + ) + } + + access(self) fun _createYieldTokenOracle( + yieldTokenEVMAddress: EVM.EVMAddress, + underlyingAssetType: Type, + uniqueID: DeFiActions.UniqueIdentifier + ): ERC4626PriceOracles.PriceOracle { + return ERC4626PriceOracles.PriceOracle( + vault: yieldTokenEVMAddress, + asset: underlyingAssetType, + uniqueID: uniqueID + ) + } + + access(self) fun _initAutoBalancerAndIO( + oracle: {DeFiActions.PriceOracle}, + yieldTokenType: Type, + recurringConfig: DeFiActions.AutoBalancerRecurringConfig?, + uniqueID: DeFiActions.UniqueIdentifier + ): FlowYieldVaultsStrategiesV2.AutoBalancerIO { + let autoBalancerRef = + FlowYieldVaultsAutoBalancers._initNewAutoBalancer( + oracle: oracle, + vaultType: yieldTokenType, + lowerThreshold: 0.95, + upperThreshold: 1.05, + rebalanceSink: nil, + rebalanceSource: nil, + recurringConfig: recurringConfig, + uniqueID: uniqueID + ) + + let sink = autoBalancerRef.createBalancerSink() + ?? panic("Could not retrieve Sink from AutoBalancer with id \(uniqueID.id)") + let source = autoBalancerRef.createBalancerSource() + ?? panic("Could not retrieve Source from AutoBalancer with id \(uniqueID.id)") + + return FlowYieldVaultsStrategiesV2.AutoBalancerIO( + autoBalancer: autoBalancerRef, + sink: sink, + source: source + ) + } + + access(self) fun _openCreditPosition( + funds: @{FungibleToken.Vault}, + issuanceSink: {DeFiActions.Sink}, + repaymentSource: {DeFiActions.Source} + ): @FlowALPv0.Position { + let poolCap = FlowYieldVaultsStrategiesV2.account.storage.copy< + Capability + >(from: FlowALPv0.PoolCapStoragePath) + ?? panic("Missing or invalid pool capability") + + let poolRef = poolCap.borrow() ?? panic("Invalid Pool Cap") + + let position <- poolRef.createPosition( + funds: <-funds, + issuanceSink: issuanceSink, + repaymentSource: repaymentSource, + pushToDrawDownSink: true + ) + + return <-position + } + + /// This StrategyComposer builds a Strategy that uses ERC4626 and MorphoERC4626 vaults. + /// Only handles FUSDEVStrategy (Morpho-based strategies that require UniV3 swap paths). access(all) resource MorphoERC4626StrategyComposer : FlowYieldVaults.StrategyComposer { - /// { Strategy Type: { Collateral Type: FlowYieldVaultsStrategiesV2.CollateralConfig } } - access(self) let config: {Type: {Type: FlowYieldVaultsStrategiesV2.CollateralConfig}} + /// { Strategy Type: { Collateral Type: CollateralConfig } } + access(self) let config: {Type: {Type: CollateralConfig}} - init(_ config: {Type: {Type: FlowYieldVaultsStrategiesV2.CollateralConfig}}) { + init(_ config: {Type: {Type: CollateralConfig}}) { self.config = config } @@ -331,7 +720,7 @@ access(all) contract FlowYieldVaultsStrategiesV2 { /// Returns the Vault types which can be used to initialize a given Strategy access(all) view fun getSupportedInitializationVaults(forStrategy: Type): {Type: Bool} { let supported: {Type: Bool} = {} - if let strategyConfig = &self.config[forStrategy] as &{Type: FlowYieldVaultsStrategiesV2.CollateralConfig}? { + if let strategyConfig = self.config[forStrategy] { for collateralType in strategyConfig.keys { supported[collateralType] = true } @@ -362,6 +751,7 @@ access(all) contract FlowYieldVaultsStrategiesV2 { ): @{FlowYieldVaults.Strategy} { pre { self.config[type] != nil: "Unsupported strategy type \(type.identifier)" + self.config[type]!.length > 0: "No collateral configured for strategy type \(type.identifier)" } let collateralType = withFunds.getType() @@ -370,10 +760,12 @@ access(all) contract FlowYieldVaultsStrategiesV2 { collateralType: collateralType ) - let tokens = self._resolveTokenBundle(collateralConfig: collateralConfig) + let tokens = FlowYieldVaultsStrategiesV2._resolveTokenBundle( + yieldTokenEVMAddress: collateralConfig.yieldTokenEVMAddress + ) // Oracle used by AutoBalancer (tracks NAV of ERC4626 vault) - let yieldTokenOracle = self._createYieldTokenOracle( + let yieldTokenOracle = FlowYieldVaultsStrategiesV2._createYieldTokenOracle( yieldTokenEVMAddress: tokens.yieldTokenEVMAddress, underlyingAssetType: tokens.underlying4626AssetType, uniqueID: uniqueID @@ -383,102 +775,92 @@ access(all) contract FlowYieldVaultsStrategiesV2 { let recurringConfig = FlowYieldVaultsStrategiesV2._createRecurringConfig(withID: uniqueID) // Create/store/publish/register AutoBalancer (returns authorized ref) - let balancerIO = self._initAutoBalancerAndIO( + let balancerIO = FlowYieldVaultsStrategiesV2._initAutoBalancerAndIO( oracle: yieldTokenOracle, yieldTokenType: tokens.yieldTokenType, recurringConfig: recurringConfig, uniqueID: uniqueID ) - // Swappers: MOET <-> YIELD (YIELD is ERC4626 vault token) - let moetToYieldSwapper = self._createMoetToYieldSwapper(strategyType: type, tokens: tokens, uniqueID: uniqueID) - - let yieldToMoetSwapper = self._createYieldToMoetSwapper(strategyType: type, tokens: tokens, uniqueID: uniqueID) - - // AutoBalancer-directed swap IO - let abaSwapSink = SwapConnectors.SwapSink( - swapper: moetToYieldSwapper, - sink: balancerIO.sink, - uniqueID: uniqueID - ) - let abaSwapSource = SwapConnectors.SwapSource( - swapper: yieldToMoetSwapper, - source: balancerIO.source, - uniqueID: uniqueID - ) + switch type { - // Open FlowALPv0 position - let position <- self._openCreditPosition( - funds: <-withFunds, - issuanceSink: abaSwapSink, - repaymentSource: abaSwapSource - ) + // ----------------------------------------------------------------------- + // FUSDEVStrategy: borrows MOET from the FlowALP position, swaps to FUSDEV + // ----------------------------------------------------------------------- + case Type<@FUSDEVStrategy>(): + // Swappers: MOET <-> YIELD + let debtToYieldSwapper = self._createDebtToYieldSwapper(tokens: tokens, uniqueID: uniqueID) + let yieldToDebtSwapper = self._createYieldToDebtSwapper(tokens: tokens, uniqueID: uniqueID) + + // AutoBalancer-directed swap IO + let abaSwapSink = SwapConnectors.SwapSink( + swapper: debtToYieldSwapper, + sink: balancerIO.sink, + uniqueID: uniqueID + ) + let abaSwapSource = SwapConnectors.SwapSource( + swapper: yieldToDebtSwapper, + source: balancerIO.source, + uniqueID: uniqueID + ) - // Position Sink/Source (only Sink needed here, Source stays inside Strategy impl) - let positionSink = position.createSinkWithOptions(type: collateralType, pushToDrawDownSink: true) + // --- Standard path (WBTC, WETH, WFLOW — directly supported by FlowALP) --- - // Yield -> Collateral swapper for recollateralization - let yieldToCollateralSwapper = self._createYieldToCollateralSwapper( - collateralConfig: collateralConfig, - yieldTokenEVMAddress: tokens.yieldTokenEVMAddress, - yieldTokenType: tokens.yieldTokenType, - collateralType: collateralType, - uniqueID: uniqueID - ) + // Open FlowALPv0 position + let position <- FlowYieldVaultsStrategiesV2._openCreditPosition( + funds: <-withFunds, + issuanceSink: abaSwapSink, + repaymentSource: abaSwapSource + ) - let positionSwapSink = SwapConnectors.SwapSink( - swapper: yieldToCollateralSwapper, - sink: positionSink, - uniqueID: uniqueID - ) + // Position Sink/Source for collateral rebalancing + let positionSink = position.createSinkWithOptions(type: collateralType, pushToDrawDownSink: true) - // pullFromTopUpSource: false ensures Position maintains health buffer - // This prevents Position from being pushed to minHealth (1.1) limit - let positionSource = position.createSourceWithOptions( - type: collateralType, - pullFromTopUpSource: false // ← CONSERVATIVE: maintain safety buffer - ) + // Yield -> Collateral swapper for recollateralization + let yieldToCollateralSwapper = self._createYieldToCollateralSwapper( + collateralConfig: collateralConfig, + yieldTokenEVMAddress: tokens.yieldTokenEVMAddress, + yieldTokenType: tokens.yieldTokenType, + collateralType: collateralType, + uniqueID: uniqueID + ) - // Create Collateral -> Yield swapper (reverse of yieldToCollateralSwapper) - // Allows AutoBalancer to pull collateral, swap to yield token - let collateralToYieldSwapper = self._createCollateralToYieldSwapper( - collateralConfig: collateralConfig, - yieldTokenEVMAddress: tokens.yieldTokenEVMAddress, - yieldTokenType: tokens.yieldTokenType, - collateralType: collateralType, - uniqueID: uniqueID - ) + let positionSwapSink = SwapConnectors.SwapSink( + swapper: yieldToCollateralSwapper, + sink: positionSink, + uniqueID: uniqueID + ) - // Create Position swap source for AutoBalancer deficit recovery - // When AutoBalancer value drops below deposits, pulls collateral from Position - let positionSwapSource = SwapConnectors.SwapSource( - swapper: collateralToYieldSwapper, - source: positionSource, - uniqueID: uniqueID - ) + // pullFromTopUpSource: false ensures Position maintains health buffer + let positionSource = position.createSourceWithOptions( + type: collateralType, + pullFromTopUpSource: false + ) - // Set AutoBalancer sink for overflow -> recollateralize - balancerIO.autoBalancer.setSink(positionSwapSink, updateSinkID: true) + // Collateral -> Yield swapper for AutoBalancer deficit recovery + let collateralToYieldSwapper = self._createCollateralToYieldSwapper( + collateralConfig: collateralConfig, + yieldTokenEVMAddress: tokens.yieldTokenEVMAddress, + yieldTokenType: tokens.yieldTokenType, + collateralType: collateralType, + uniqueID: uniqueID + ) - // Set AutoBalancer source for deficit recovery -> pull from Position - balancerIO.autoBalancer.setSource(positionSwapSource, updateSourceID: true) + let positionSwapSource = SwapConnectors.SwapSource( + swapper: collateralToYieldSwapper, + source: positionSource, + uniqueID: uniqueID + ) - // Store yield→MOET swapper in contract config for later access during closePosition - let yieldToMoetSwapperKey = FlowYieldVaultsStrategiesV2.getYieldToMoetSwapperConfigKey(uniqueID)! - FlowYieldVaultsStrategiesV2.config[yieldToMoetSwapperKey] = yieldToMoetSwapper + balancerIO.autoBalancer.setSink(positionSwapSink, updateSinkID: true) + balancerIO.autoBalancer.setSource(positionSwapSource, updateSourceID: true) - // @TODO implement moet to collateral swapper - // let moetToCollateralSwapperKey = FlowYieldVaultsStrategiesV2.getMoetToCollateralSwapperConfigKey(uniqueID) - // - // FlowYieldVaultsStrategiesV2.config[moetToCollateralSwapperKey] = moetToCollateralSwapper - // - switch type { - case Type<@FUSDEVStrategy>(): return <-create FUSDEVStrategy( id: uniqueID, collateralType: collateralType, position: <-position ) + default: panic("Unsupported strategy type \(type.identifier)") } @@ -498,56 +880,7 @@ access(all) contract FlowYieldVaultsStrategiesV2 { ) return strategyConfig[collateralType] - ?? panic( - "Could not find config for collateral \(collateralType.identifier) when creating Strategy \(strategyType.identifier)" - ) - } - - access(self) fun _resolveTokenBundle( - collateralConfig: FlowYieldVaultsStrategiesV2.CollateralConfig - ): FlowYieldVaultsStrategiesV2.TokenBundle { - // MOET - let moetTokenType = Type<@MOET.Vault>() - let moetTokenEVMAddress = FlowEVMBridgeConfig.getEVMAddressAssociated(with: moetTokenType) - ?? panic("Token Vault type \(moetTokenType.identifier) has not yet been registered with the VMbridge") - - // YIELD (ERC4626 vault token) - let yieldTokenEVMAddress = collateralConfig.yieldTokenEVMAddress - let yieldTokenType = FlowEVMBridgeConfig.getTypeAssociated(with: yieldTokenEVMAddress) - ?? panic( - "Could not retrieve the VM Bridge associated Type for the yield token address \(yieldTokenEVMAddress.toString())" - ) - - // UNDERLYING asset of the ERC4626 vault - let underlying4626AssetEVMAddress = ERC4626Utils.underlyingAssetEVMAddress(vault: yieldTokenEVMAddress) - ?? panic( - "Could not get the underlying asset's EVM address for ERC4626Vault \(yieldTokenEVMAddress.toString())" - ) - let underlying4626AssetType = FlowEVMBridgeConfig.getTypeAssociated(with: underlying4626AssetEVMAddress) - ?? panic( - "Could not retrieve the VM Bridge associated Type for the ERC4626 underlying asset \(underlying4626AssetEVMAddress.toString())" - ) - - return FlowYieldVaultsStrategiesV2.TokenBundle( - moetTokenType: moetTokenType, - moetTokenEVMAddress: moetTokenEVMAddress, - yieldTokenType: yieldTokenType, - yieldTokenEVMAddress: yieldTokenEVMAddress, - underlying4626AssetType: underlying4626AssetType, - underlying4626AssetEVMAddress: underlying4626AssetEVMAddress - ) - } - - access(self) fun _createYieldTokenOracle( - yieldTokenEVMAddress: EVM.EVMAddress, - underlyingAssetType: Type, - uniqueID: DeFiActions.UniqueIdentifier - ): ERC4626PriceOracles.PriceOracle { - return ERC4626PriceOracles.PriceOracle( - vault: yieldTokenEVMAddress, - asset: underlyingAssetType, - uniqueID: uniqueID - ) + ?? panic("Could not find config for collateral \(collateralType.identifier)") } access(self) fun _createUniV3Swapper( @@ -570,13 +903,12 @@ access(all) contract FlowYieldVaultsStrategiesV2 { ) } - access(self) fun _createMoetToYieldSwapper( - strategyType: Type, + access(self) fun _createDebtToYieldSwapper( tokens: FlowYieldVaultsStrategiesV2.TokenBundle, uniqueID: DeFiActions.UniqueIdentifier ): SwapConnectors.MultiSwapper { // Direct MOET -> YIELD via AMM - let moetToYieldAMM = self._createUniV3Swapper( + let debtToYieldAMM = self._createUniV3Swapper( tokenPath: [tokens.moetTokenEVMAddress, tokens.yieldTokenEVMAddress], feePath: [100], inVault: tokens.moetTokenType, @@ -585,7 +917,7 @@ access(all) contract FlowYieldVaultsStrategiesV2 { ) // MOET -> UNDERLYING via AMM - let moetToUnderlying = self._createUniV3Swapper( + let debtToUnderlying = self._createUniV3Swapper( tokenPath: [tokens.moetTokenEVMAddress, tokens.underlying4626AssetEVMAddress], feePath: [100], inVault: tokens.moetTokenType, @@ -593,47 +925,34 @@ access(all) contract FlowYieldVaultsStrategiesV2 { uniqueID: uniqueID ) - // UNDERLYING -> YIELD via ERC4626 vault - // Morpho vaults use MorphoERC4626SwapConnectors; standard ERC4626 vaults use ERC4626SwapConnectors - var underlyingTo4626: {DeFiActions.Swapper}? = nil - if strategyType == Type<@FUSDEVStrategy>() { - underlyingTo4626 = MorphoERC4626SwapConnectors.Swapper( - vaultEVMAddress: tokens.yieldTokenEVMAddress, - coa: FlowYieldVaultsStrategiesV2._getCOACapability(), - feeSource: FlowYieldVaultsStrategiesV2._createFeeSource(withID: uniqueID), - uniqueID: uniqueID, - isReversed: false - ) - } else { - underlyingTo4626 = ERC4626SwapConnectors.Swapper( - asset: tokens.underlying4626AssetType, - vault: tokens.yieldTokenEVMAddress, - coa: FlowYieldVaultsStrategiesV2._getCOACapability(), - feeSource: FlowYieldVaultsStrategiesV2._createFeeSource(withID: uniqueID), - uniqueID: uniqueID - ) - } + // UNDERLYING -> YIELD via Morpho ERC4626 vault deposit + let underlyingTo4626 = MorphoERC4626SwapConnectors.Swapper( + vaultEVMAddress: tokens.yieldTokenEVMAddress, + coa: FlowYieldVaultsStrategiesV2._getCOACapability(), + feeSource: FlowYieldVaultsStrategiesV2._createFeeSource(withID: uniqueID), + uniqueID: uniqueID, + isReversed: false + ) let seq = SwapConnectors.SequentialSwapper( - swappers: [moetToUnderlying, underlyingTo4626!], + swappers: [debtToUnderlying, underlyingTo4626], uniqueID: uniqueID ) return SwapConnectors.MultiSwapper( inVault: tokens.moetTokenType, outVault: tokens.yieldTokenType, - swappers: [moetToYieldAMM, seq], + swappers: [debtToYieldAMM, seq], uniqueID: uniqueID ) } - access(self) fun _createYieldToMoetSwapper( - strategyType: Type, + access(self) fun _createYieldToDebtSwapper( tokens: FlowYieldVaultsStrategiesV2.TokenBundle, uniqueID: DeFiActions.UniqueIdentifier ): SwapConnectors.MultiSwapper { // Direct YIELD -> MOET via AMM - let yieldToMoetAMM = self._createUniV3Swapper( + let yieldToDebtAMM = self._createUniV3Swapper( tokenPath: [tokens.yieldTokenEVMAddress, tokens.moetTokenEVMAddress], feePath: [100], inVault: tokens.yieldTokenType, @@ -641,108 +960,34 @@ access(all) contract FlowYieldVaultsStrategiesV2 { uniqueID: uniqueID ) - // Reverse path: Morpho vaults support direct redeem; standard ERC4626 vaults use AMM-only path - if strategyType == Type<@FUSDEVStrategy>() { - // YIELD -> UNDERLYING redeem via MorphoERC4626 vault - let yieldToUnderlying = MorphoERC4626SwapConnectors.Swapper( - vaultEVMAddress: tokens.yieldTokenEVMAddress, - coa: FlowYieldVaultsStrategiesV2._getCOACapability(), - feeSource: FlowYieldVaultsStrategiesV2._createFeeSource(withID: uniqueID), - uniqueID: uniqueID, - isReversed: true - ) - // UNDERLYING -> MOET via AMM - let underlyingToMoet = self._createUniV3Swapper( - tokenPath: [tokens.underlying4626AssetEVMAddress, tokens.moetTokenEVMAddress], - feePath: [100], - inVault: tokens.underlying4626AssetType, - outVault: tokens.moetTokenType, - uniqueID: uniqueID - ) - - let seq = SwapConnectors.SequentialSwapper( - swappers: [yieldToUnderlying, underlyingToMoet], - uniqueID: uniqueID - ) - - return SwapConnectors.MultiSwapper( - inVault: tokens.yieldTokenType, - outVault: tokens.moetTokenType, - swappers: [yieldToMoetAMM, seq], - uniqueID: uniqueID - ) - } else { - // Standard ERC4626: AMM-only reverse (no synchronous redeem support) - return SwapConnectors.MultiSwapper( - inVault: tokens.yieldTokenType, - outVault: tokens.moetTokenType, - swappers: [yieldToMoetAMM], - uniqueID: uniqueID - ) - } - } - - /// @TODO - /// implement moet to collateral swapper - // access(self) fun _createMoetToCollateralSwapper( - // strategyType: Type, - // tokens: FlowYieldVaultsStrategiesV2.TokenBundle, - // uniqueID: DeFiActions.UniqueIdentifier - // ): SwapConnectors.MultiSwapper { - // // Direct MOET -> underlying via AMM - // } - - access(self) fun _initAutoBalancerAndIO( - oracle: {DeFiActions.PriceOracle}, - yieldTokenType: Type, - recurringConfig: DeFiActions.AutoBalancerRecurringConfig?, - uniqueID: DeFiActions.UniqueIdentifier - ): FlowYieldVaultsStrategiesV2.AutoBalancerIO { - // NOTE: This stores the AutoBalancer in FlowYieldVaultsAutoBalancers storage and returns an authorized ref. - let autoBalancerRef = - FlowYieldVaultsAutoBalancers._initNewAutoBalancer( - oracle: oracle, - vaultType: yieldTokenType, - lowerThreshold: 0.95, - upperThreshold: 1.05, - rebalanceSink: nil, - rebalanceSource: nil, - recurringConfig: recurringConfig, - uniqueID: uniqueID - ) - - let sink = autoBalancerRef.createBalancerSink() - ?? panic("Could not retrieve Sink from AutoBalancer with id \(uniqueID.id)") - let source = autoBalancerRef.createBalancerSource() - ?? panic("Could not retrieve Source from AutoBalancer with id \(uniqueID.id)") - - return FlowYieldVaultsStrategiesV2.AutoBalancerIO( - autoBalancer: autoBalancerRef, - sink: sink, - source: source + // YIELD -> UNDERLYING redeem via MorphoERC4626 vault + let yieldToUnderlying = MorphoERC4626SwapConnectors.Swapper( + vaultEVMAddress: tokens.yieldTokenEVMAddress, + coa: FlowYieldVaultsStrategiesV2._getCOACapability(), + feeSource: FlowYieldVaultsStrategiesV2._createFeeSource(withID: uniqueID), + uniqueID: uniqueID, + isReversed: true + ) + // UNDERLYING -> MOET via AMM + let underlyingToDebt = self._createUniV3Swapper( + tokenPath: [tokens.underlying4626AssetEVMAddress, tokens.moetTokenEVMAddress], + feePath: [100], + inVault: tokens.underlying4626AssetType, + outVault: tokens.moetTokenType, + uniqueID: uniqueID ) - } - access(self) fun _openCreditPosition( - funds: @{FungibleToken.Vault}, - issuanceSink: {DeFiActions.Sink}, - repaymentSource: {DeFiActions.Source} - ): @FlowALPv0.Position { - let poolCap = FlowYieldVaultsStrategiesV2.account.storage.copy< - Capability - >(from: FlowALPv0.PoolCapStoragePath) - ?? panic("Missing or invalid pool capability") - - let poolRef = poolCap.borrow() ?? panic("Invalid Pool Cap") - - let position <- poolRef.createPosition( - funds: <-funds, - issuanceSink: issuanceSink, - repaymentSource: repaymentSource, - pushToDrawDownSink: true + let seq = SwapConnectors.SequentialSwapper( + swappers: [yieldToUnderlying, underlyingToDebt], + uniqueID: uniqueID ) - return <-position + return SwapConnectors.MultiSwapper( + inVault: tokens.yieldTokenType, + outVault: tokens.moetTokenType, + swappers: [yieldToDebtAMM, seq], + uniqueID: uniqueID + ) } access(self) fun _createYieldToCollateralSwapper( @@ -807,6 +1052,43 @@ access(all) contract FlowYieldVaultsStrategiesV2 { uniqueID: uniqueID ) } + + /// Creates a Collateral → Debt (MOET) swapper using UniswapV3. + /// Path: collateral → underlying (PYUSD0) → MOET + /// + /// The fee for collateral→underlying is the last fee in yieldToCollateral (reversed), + /// and the fee for underlying→MOET is fixed at 100 (0.01%, matching yieldToDebtSwapper). + /// Stored and used by FUSDEVStrategy.closePosition to pre-reduce position debt from + /// collateral when yield tokens alone cannot cover the full outstanding MOET debt. + /// + access(self) fun _createCollateralToDebtSwapper( + collateralConfig: FlowYieldVaultsStrategiesV2.CollateralConfig, + tokens: FlowYieldVaultsStrategiesV2.TokenBundle, + collateralType: Type, + uniqueID: DeFiActions.UniqueIdentifier + ): UniswapV3SwapConnectors.Swapper { + let yieldToCollPath = collateralConfig.yieldToCollateralUniV3AddressPath + let yieldToCollFees = collateralConfig.yieldToCollateralUniV3FeePath + + // collateral EVM address = last element of yieldToCollateral path + // underlying (PYUSD0) EVM address = second element of yieldToCollateral path + assert(yieldToCollPath.length >= 2, message: "yieldToCollateral path requires at least yield and collateral tokens, got \(yieldToCollPath.length)") + let collateralEVMAddress = yieldToCollPath[yieldToCollPath.length - 1] + let underlyingEVMAddress = tokens.underlying4626AssetEVMAddress + + // fee[0] = collateral→underlying = last fee in yieldToCollateral (reversed) + // fee[1] = underlying→MOET = 100 (0.01%, matching _createYieldToDebtSwapper) + let collateralToUnderlyingFee = yieldToCollFees[yieldToCollFees.length - 1] + let underlyingToDebtFee: UInt32 = 100 + + return self._createUniV3Swapper( + tokenPath: [collateralEVMAddress, underlyingEVMAddress, tokens.moetTokenEVMAddress], + feePath: [collateralToUnderlyingFee, underlyingToDebtFee], + inVault: collateralType, + outVault: tokens.moetTokenType, + uniqueID: uniqueID + ) + } } access(all) entitlement Configure @@ -832,14 +1114,16 @@ access(all) contract FlowYieldVaultsStrategiesV2 { yieldToCollateralUniV3FeePath: yieldToCollateralFeePath ) } + /// This resource enables the issuance of StrategyComposers, thus safeguarding the issuance of Strategies which /// may utilize resource consumption (i.e. account storage). Since Strategy creation consumes account storage /// via configured AutoBalancers access(all) resource StrategyComposerIssuer : FlowYieldVaults.StrategyComposerIssuer { - /// { StrategyComposer Type: { Strategy Type: { Collateral Type: FlowYieldVaultsStrategiesV2.CollateralConfig } } } - access(all) var configs: {Type: {Type: {Type: FlowYieldVaultsStrategiesV2.CollateralConfig}}} + /// { Composer Type: { Strategy Type: { Collateral Type: CollateralConfig } } } + /// Used by MorphoERC4626StrategyComposer. + access(all) var configs: {Type: {Type: {Type: CollateralConfig}}} - init(configs: {Type: {Type: {Type: FlowYieldVaultsStrategiesV2.CollateralConfig}}}) { + init(configs: {Type: {Type: {Type: CollateralConfig}}}) { self.configs = configs } @@ -848,9 +1132,9 @@ access(all) contract FlowYieldVaultsStrategiesV2 { strategy: Type, collateral: Type ): Bool { - if let composerConfig = self.configs[composer] { - if let strategyConfig = composerConfig[strategy] { - return strategyConfig[collateral] != nil + if let composerPartition = self.configs[composer] { + if let stratPartition = composerPartition[strategy] { + if stratPartition[collateral] != nil { return true } } } return false @@ -862,35 +1146,44 @@ access(all) contract FlowYieldVaultsStrategiesV2 { } } + /// Returns CollateralConfig for the given strategy+collateral, by value (not reference). + /// Called from contract-level _getStoredCollateralConfig to avoid reference-chain issues. + access(all) fun getCollateralConfig( + strategyType: Type, + collateralType: Type + ): CollateralConfig? { + let composerType = Type<@MorphoERC4626StrategyComposer>() + if let p0 = self.configs[composerType] { + if let p1 = p0[strategyType] { + return p1[collateralType] + } + } + return nil + } + access(self) view fun isSupportedComposer(_ type: Type): Bool { return type == Type<@MorphoERC4626StrategyComposer>() } + access(all) fun issueComposer(_ type: Type): @{FlowYieldVaults.StrategyComposer} { pre { - self.isSupportedComposer(type) == true: - "Unsupported StrategyComposer \(type.identifier) requested" - self.configs[type] != nil: - "Could not find config for StrategyComposer \(type.identifier)" + self.isSupportedComposer(type): "Unsupported StrategyComposer \(type.identifier) requested" } switch type { case Type<@MorphoERC4626StrategyComposer>(): - return <- create MorphoERC4626StrategyComposer(self.configs[type]!) + return <- create MorphoERC4626StrategyComposer( + self.configs[type] ?? panic("No config registered for \(type.identifier)") + ) default: panic("Unsupported StrategyComposer \(type.identifier) requested") } } + /// Merges new CollateralConfig entries into the MorphoERC4626StrategyComposer config. access(Configure) - fun upsertConfigFor( - composer: Type, + fun upsertMorphoConfig( config: {Type: {Type: FlowYieldVaultsStrategiesV2.CollateralConfig}} ) { - pre { - self.isSupportedComposer(composer) == true: - "Unsupported StrategyComposer Type \(composer.identifier)" - } - - // Validate keys for stratType in config.keys { assert(stratType.isSubtype(of: Type<@{FlowYieldVaults.Strategy}>()), message: "Invalid config key \(stratType.identifier) - not a FlowYieldVaults.Strategy Type") @@ -900,26 +1193,20 @@ access(all) contract FlowYieldVaultsStrategiesV2 { } } - // Merge instead of overwrite - let existingComposerConfig = self.configs[composer] ?? {} - var mergedComposerConfig = existingComposerConfig - + let composerType = Type<@MorphoERC4626StrategyComposer>() + var composerPartition = self.configs[composerType] ?? {} for stratType in config.keys { + var stratPartition: {Type: CollateralConfig} = composerPartition[stratType] ?? {} let newPerCollateral = config[stratType]! - let existingPerCollateral = mergedComposerConfig[stratType] ?? {} - var mergedPerCollateral: {Type: FlowYieldVaultsStrategiesV2.CollateralConfig} = existingPerCollateral - for collateralType in newPerCollateral.keys { - mergedPerCollateral[collateralType] = newPerCollateral[collateralType]! + stratPartition[collateralType] = newPerCollateral[collateralType]! } - mergedComposerConfig[stratType] = mergedPerCollateral + composerPartition[stratType] = stratPartition } - - self.configs[composer] = mergedComposerConfig + self.configs[composerType] = composerPartition } - access(Configure) fun addOrUpdateCollateralConfig( - composer: Type, + access(Configure) fun addOrUpdateMorphoCollateralConfig( strategyType: Type, collateralVaultType: Type, yieldTokenEVMAddress: EVM.EVMAddress, @@ -927,34 +1214,24 @@ access(all) contract FlowYieldVaultsStrategiesV2 { yieldToCollateralFeePath: [UInt32] ) { pre { - self.isSupportedComposer(composer) == true: - "Unsupported StrategyComposer Type \(composer.identifier)" strategyType.isSubtype(of: Type<@{FlowYieldVaults.Strategy}>()): "Strategy type \(strategyType.identifier) is not a FlowYieldVaults.Strategy" collateralVaultType.isSubtype(of: Type<@{FungibleToken.Vault}>()): "Collateral type \(collateralVaultType.identifier) is not a FungibleToken.Vault" } - // Base struct with shared addresses - var base = FlowYieldVaultsStrategiesV2.makeCollateralConfig( + let base = FlowYieldVaultsStrategiesV2.makeCollateralConfig( yieldTokenEVMAddress: yieldTokenEVMAddress, yieldToCollateralAddressPath: yieldToCollateralAddressPath, yieldToCollateralFeePath: yieldToCollateralFeePath ) - - // Wrap into the nested config expected by upsertConfigFor - let singleCollateralConfig = { - strategyType: { - collateralVaultType: base - } - } - - self.upsertConfigFor(composer: composer, config: singleCollateralConfig) + self.upsertMorphoConfig(config: { strategyType: { collateralVaultType: base } }) } + access(Configure) fun purgeConfig() { self.configs = { Type<@MorphoERC4626StrategyComposer>(): { - Type<@FUSDEVStrategy>(): {} as {Type: FlowYieldVaultsStrategiesV2.CollateralConfig} + Type<@FUSDEVStrategy>(): {} as {Type: CollateralConfig} } } } @@ -964,9 +1241,14 @@ access(all) contract FlowYieldVaultsStrategiesV2 { /// TODO: this is temporary until we have a better way to pass user's COAs to inner connectors access(self) fun _getCOACapability(): Capability { - let coaCap = self.account.capabilities.storage.issue(/storage/evm) - assert(coaCap.check(), message: "Could not issue COA capability") - return coaCap + let capPath = /storage/strategiesCOACap + if self.account.storage.type(at: capPath) == nil { + let coaCap = self.account.capabilities.storage.issue(/storage/evm) + assert(coaCap.check(), message: "Could not issue COA capability") + self.account.storage.save(coaCap, to: capPath) + } + return self.account.storage.copy>(from: capPath) + ?? panic("Could not load COA capability from storage") } /// Returns a FungibleTokenConnectors.VaultSinkAndSource used to subsidize cross VM token movement in contract- @@ -1022,18 +1304,25 @@ access(all) contract FlowYieldVaultsStrategiesV2 { ) } - access(self) view fun getYieldToMoetSwapperConfigKey(_ uniqueID: DeFiActions.UniqueIdentifier?): String { - pre { - uniqueID != nil: "Missing UniqueIdentifier for swapper config key" - } - return "yieldToMoetSwapper_\(uniqueID!.id.toString())" - } - - access(self) view fun getMoetToCollateralSwapperConfigKey(_ uniqueID: DeFiActions.UniqueIdentifier?): String { - pre { - uniqueID != nil: "Missing UniqueIdentifier for swapper config key" - } - return "moetToCollateralSwapper_\(uniqueID!.id.toString())" + /// Builds a UniswapV3 swapper. Shared by FUSDEVStrategy and syWFLOWvStrategy closePosition helpers. + access(self) fun _buildUniV3Swapper( + tokenPath: [EVM.EVMAddress], + feePath: [UInt32], + inVault: Type, + outVault: Type, + uniqueID: DeFiActions.UniqueIdentifier + ): UniswapV3SwapConnectors.Swapper { + return UniswapV3SwapConnectors.Swapper( + factoryAddress: FlowYieldVaultsStrategiesV2.univ3FactoryEVMAddress, + routerAddress: FlowYieldVaultsStrategiesV2.univ3RouterEVMAddress, + quoterAddress: FlowYieldVaultsStrategiesV2.univ3QuoterEVMAddress, + tokenPath: tokenPath, + feePath: feePath, + inVault: inVault, + outVault: outVault, + coaCapability: FlowYieldVaultsStrategiesV2._getCOACapability(), + uniqueID: uniqueID + ) } init( @@ -1052,12 +1341,14 @@ access(all) contract FlowYieldVaultsStrategiesV2 { panic("Could not find EVM address for \(moetType.identifier) - ensure the asset is onboarded to the VM Bridge") } - let configs = { + let issuer <- create StrategyComposerIssuer( + configs: { Type<@MorphoERC4626StrategyComposer>(): { - Type<@FUSDEVStrategy>(): {} as {Type: FlowYieldVaultsStrategiesV2.CollateralConfig} + Type<@FUSDEVStrategy>(): {} as {Type: CollateralConfig} } } - self.account.storage.save(<-create StrategyComposerIssuer(configs: configs), to: self.IssuerStoragePath) + ) + self.account.storage.save(<-issuer, to: self.IssuerStoragePath) // TODO: this is temporary until we have a better way to pass user's COAs to inner connectors // create a COA in this account diff --git a/cadence/tests/FlowYieldVaultsStrategiesV2_FUSDEV_test.cdc b/cadence/tests/FlowYieldVaultsStrategiesV2_FUSDEV_test.cdc new file mode 100644 index 00000000..404b6113 --- /dev/null +++ b/cadence/tests/FlowYieldVaultsStrategiesV2_FUSDEV_test.cdc @@ -0,0 +1,563 @@ +#test_fork(network: "mainnet", height: nil) + +import Test + +import "EVM" +import "FlowToken" +import "FlowYieldVaults" +import "FlowYieldVaultsClosedBeta" + +/// Fork test for FlowYieldVaultsStrategiesV2 FUSDEVStrategy. +/// +/// Tests the full YieldVault lifecycle (create, deposit, withdraw, close) for each supported +/// collateral type: WFLOW (FlowToken), WBTC, and WETH. +/// +/// PYUSD0 cannot be used as collateral — it is the FUSDEV vault's underlying asset. The +/// test setup intentionally omits a PYUSD0 collateral config so that negative tests can +/// assert the correct rejection. +/// +/// Strategy: +/// → FlowALP borrow MOET → swap MOET→PYUSD0 → ERC4626 deposit → FUSDEV (Morpho vault) +/// Close: FUSDEV → PYUSD0 (redeem) → MOET → repay FlowALP → returned to user +/// +/// Mainnet addresses: +/// - Admin (FlowYieldVaults deployer): 0xb1d63873c3cc9f79 +/// - WFLOW/PYUSD0 negative-test user: 0x443472749ebdaac8 (holds PYUSD0 and FLOW on mainnet) +/// - WBTC/WETH user: 0x68da18f20e98a7b6 (has ~12 WETH in EVM COA; WETH bridged + WBTC swapped in setup) +/// - UniV3 Factory: 0xca6d7Bb03334bBf135902e1d919a5feccb461632 +/// - UniV3 Router: 0xeEDC6Ff75e1b10B903D9013c358e446a73d35341 +/// - UniV3 Quoter: 0x370A8DF17742867a44e56223EC20D82092242C85 +/// - FUSDEV (Morpho ERC4626): 0xd069d989e2F44B70c65347d1853C0c67e10a9F8D +/// - PYUSD0: 0x99aF3EeA856556646C98c8B9b2548Fe815240750 +/// - WFLOW: 0xd3bF53DAC106A0290B0483EcBC89d40FcC961f3e +/// - WBTC: 0x717DAE2BaF7656BE9a9B01deE31d571a9d4c9579 (cbBTC; no WFLOW pool — use WETH as intermediate) +/// - WETH: 0x2F6F07CDcf3588944Bf4C42aC74ff24bF56e7590 + +// --- Accounts --- + +/// Mainnet admin account — deployer of FlowYieldVaults, FlowYieldVaultsClosedBeta, FlowYieldVaultsStrategiesV2 +access(all) let adminAccount = Test.getAccount(0xb1d63873c3cc9f79) + +/// WFLOW test user — holds FLOW (and PYUSD0) on mainnet. +/// Used for WFLOW lifecycle tests and for the negative PYUSD0 collateral test. +access(all) let flowUser = Test.getAccount(0x443472749ebdaac8) + +/// FlowToken contract account — used to provision FLOW to flowUser in setup. +access(all) let flowTokenAccount = Test.getAccount(0x1654653399040a61) + +/// WBTC/WETH holder — this account has ~12 WETH in its EVM COA on mainnet. +/// WETH is bridged to Cadence during setup(), and some WETH is then swapped → WBTC +/// via the UniV3 WETH/WBTC pool so that both collateral types can be tested. +/// COA EVM: 0x000000000000000000000002b87c966bc00bc2c4 +access(all) let wbtcUser = Test.getAccount(0x68da18f20e98a7b6) +access(all) let wethUser = Test.getAccount(0x68da18f20e98a7b6) + +// --- Strategy Config --- + +access(all) let fusdEvStrategyIdentifier = "A.b1d63873c3cc9f79.FlowYieldVaultsStrategiesV2.FUSDEVStrategy" +access(all) let composerIdentifier = "A.b1d63873c3cc9f79.FlowYieldVaultsStrategiesV2.MorphoERC4626StrategyComposer" +access(all) let issuerStoragePath: StoragePath = /storage/FlowYieldVaultsStrategyV2ComposerIssuer_0xb1d63873c3cc9f79 + +// --- Cadence Vault Type Identifiers --- + +/// FlowToken (WFLOW on EVM side) — used as WFLOW collateral +access(all) let flowVaultIdentifier = "A.1654653399040a61.FlowToken.Vault" +/// VM-bridged ERC-20 tokens +access(all) let wbtcVaultIdentifier = "A.1e4aa0b87d10b141.EVMVMBridgedToken_717dae2baf7656be9a9b01dee31d571a9d4c9579.Vault" +access(all) let wethVaultIdentifier = "A.1e4aa0b87d10b141.EVMVMBridgedToken_2f6f07cdcf3588944bf4c42ac74ff24bf56e7590.Vault" +access(all) let pyusd0VaultIdentifier = "A.1e4aa0b87d10b141.EVMVMBridgedToken_99af3eea856556646c98c8b9b2548fe815240750.Vault" + +// --- EVM Addresses --- + +access(all) let fusdEvEVMAddress = "0xd069d989e2F44B70c65347d1853C0c67e10a9F8D" +access(all) let pyusd0EVMAddress = "0x99aF3EeA856556646C98c8B9b2548Fe815240750" +access(all) let wflowEVMAddress = "0xd3bF53DAC106A0290B0483EcBC89d40FcC961f3e" +access(all) let wbtcEVMAddress = "0x717DAE2BaF7656BE9a9B01deE31d571a9d4c9579" +access(all) let wethEVMAddress = "0x2F6F07CDcf3588944Bf4C42aC74ff24bF56e7590" + +// --- Test State (vault IDs set during create tests, read by subsequent tests) --- + +access(all) var flowVaultID: UInt64 = 0 +access(all) var wbtcVaultID: UInt64 = 0 +access(all) var wethVaultID: UInt64 = 0 + +/* --- Helpers --- */ + +access(all) +fun _executeScript(_ path: String, _ args: [AnyStruct]): Test.ScriptResult { + return Test.executeScript(Test.readFile(path), args) +} + +access(all) +fun _executeTransactionFile(_ path: String, _ args: [AnyStruct], _ signers: [Test.TestAccount]): Test.TransactionResult { + let txn = Test.Transaction( + code: Test.readFile(path), + authorizers: signers.map(fun (s: Test.TestAccount): Address { return s.address }), + signers: signers, + arguments: args + ) + return Test.executeTransaction(txn) +} + +access(all) +fun equalAmounts(a: UFix64, b: UFix64, tolerance: UFix64): Bool { + if a > b { return a - b <= tolerance } + return b - a <= tolerance +} + +/// Returns the most-recently-created YieldVault ID for the given account. +access(all) +fun _latestVaultID(_ user: Test.TestAccount): UInt64 { + let r = _executeScript("../scripts/flow-yield-vaults/get_yield_vault_ids.cdc", [user.address]) + Test.expect(r, Test.beSucceeded()) + let ids = r.returnValue! as! [UInt64]? + Test.assert(ids != nil && ids!.length > 0, message: "Expected at least one yield vault for ".concat(user.address.toString())) + return ids![ids!.length - 1] +} + +/* --- Setup --- */ + +access(all) fun setup() { + log("==== FlowYieldVaultsStrategiesV2 FUSDEV Fork Test Setup ====") + + log("Deploying EVMAmountUtils...") + var err = Test.deployContract( + name: "EVMAmountUtils", + path: "../../lib/FlowALP/FlowActions/cadence/contracts/connectors/evm/EVMAmountUtils.cdc", + arguments: [] + ) + Test.expect(err, Test.beNil()) + + log("Deploying UniswapV3SwapConnectors...") + err = Test.deployContract( + name: "UniswapV3SwapConnectors", + path: "../../lib/FlowALP/FlowActions/cadence/contracts/connectors/evm/UniswapV3SwapConnectors.cdc", + arguments: [] + ) + Test.expect(err, Test.beNil()) + + log("Deploying ERC4626Utils...") + err = Test.deployContract( + name: "ERC4626Utils", + path: "../../lib/FlowALP/FlowActions/cadence/contracts/utils/ERC4626Utils.cdc", + arguments: [] + ) + Test.expect(err, Test.beNil()) + + log("Deploying ERC4626SwapConnectors...") + err = Test.deployContract( + name: "ERC4626SwapConnectors", + path: "../../lib/FlowALP/FlowActions/cadence/contracts/connectors/evm/ERC4626SwapConnectors.cdc", + arguments: [] + ) + Test.expect(err, Test.beNil()) + + // MorphoERC4626SinkConnectors must come before MorphoERC4626SwapConnectors (it imports it). + log("Deploying MorphoERC4626SinkConnectors...") + err = Test.deployContract( + name: "MorphoERC4626SinkConnectors", + path: "../../lib/FlowALP/FlowActions/cadence/contracts/connectors/evm/morpho/MorphoERC4626SinkConnectors.cdc", + arguments: [] + ) + Test.expect(err, Test.beNil()) + + log("Deploying MorphoERC4626SwapConnectors...") + err = Test.deployContract( + name: "MorphoERC4626SwapConnectors", + path: "../../lib/FlowALP/FlowActions/cadence/contracts/connectors/evm/morpho/MorphoERC4626SwapConnectors.cdc", + arguments: [] + ) + Test.expect(err, Test.beNil()) + + log("Deploying FlowYieldVaults...") + err = Test.deployContract( + name: "FlowYieldVaults", + path: "../../cadence/contracts/FlowYieldVaults.cdc", + arguments: [] + ) + Test.expect(err, Test.beNil()) + + log("Deploying ERC4626PriceOracles...") + err = Test.deployContract( + name: "ERC4626PriceOracles", + path: "../../lib/FlowALP/FlowActions/cadence/contracts/connectors/evm/ERC4626PriceOracles.cdc", + arguments: [] + ) + Test.expect(err, Test.beNil()) + + log("Deploying FlowALPv0...") + err = Test.deployContract( + name: "FlowALPv0", + path: "../../lib/FlowALP/cadence/contracts/FlowALPv0.cdc", + arguments: [] + ) + Test.expect(err, Test.beNil()) + + log("Deploying FlowYieldVaultsAutoBalancers...") + err = Test.deployContract( + name: "FlowYieldVaultsAutoBalancers", + path: "../../cadence/contracts/FlowYieldVaultsAutoBalancers.cdc", + arguments: [] + ) + Test.expect(err, Test.beNil()) + + // temporary commented until merged with syWFLOW strategy + // log("Deploying FlowYieldVaultsStrategiesV2...") + // err = Test.deployContract( + // name: "FlowYieldVaultsStrategiesV2", + // path: "../../cadence/contracts/FlowYieldVaultsStrategiesV2.cdc", + // arguments: [ + // "0xca6d7Bb03334bBf135902e1d919a5feccb461632", + // "0xeEDC6Ff75e1b10B903D9013c358e446a73d35341", + // "0x370A8DF17742867a44e56223EC20D82092242C85" + // ] + // ) + // Test.expect(err, Test.beNil()) + + // Configure UniV3 paths for FUSDEVStrategy. + // Closing direction: FUSDEV → PYUSD0 (Morpho redeem, fee 100) → (UniV3 swap, fee 3000). + // PYUSD0 is intentionally NOT configured as collateral — it is the underlying asset. + + log("Configuring FUSDEVStrategy + WFLOW (FUSDEV→PYUSD0→WFLOW fees 100/3000)...") + var result = _executeTransactionFile( + "../transactions/flow-yield-vaults/admin/upsert_strategy_config.cdc", + [ + fusdEvStrategyIdentifier, + flowVaultIdentifier, + fusdEvEVMAddress, + [fusdEvEVMAddress, pyusd0EVMAddress, wflowEVMAddress], + [100 as UInt32, 3000 as UInt32] + ], + [adminAccount] + ) + Test.expect(result, Test.beSucceeded()) + + // No WFLOW/WBTC pool on Flow EVM — PYUSD0 is the intermediate for both legs. + log("Configuring FUSDEVStrategy + WBTC (FUSDEV→PYUSD0→WBTC fees 100/3000)...") + result = _executeTransactionFile( + "../transactions/flow-yield-vaults/admin/upsert_strategy_config.cdc", + [ + fusdEvStrategyIdentifier, + wbtcVaultIdentifier, + fusdEvEVMAddress, + [fusdEvEVMAddress, pyusd0EVMAddress, wbtcEVMAddress], + [100 as UInt32, 3000 as UInt32] + ], + [adminAccount] + ) + Test.expect(result, Test.beSucceeded()) + + log("Configuring FUSDEVStrategy + WETH (FUSDEV→PYUSD0→WETH fees 100/3000)...") + result = _executeTransactionFile( + "../transactions/flow-yield-vaults/admin/upsert_strategy_config.cdc", + [ + fusdEvStrategyIdentifier, + wethVaultIdentifier, + fusdEvEVMAddress, + [fusdEvEVMAddress, pyusd0EVMAddress, wethEVMAddress], + [100 as UInt32, 3000 as UInt32] + ], + [adminAccount] + ) + Test.expect(result, Test.beSucceeded()) + + // Register FUSDEVStrategy in the FlowYieldVaults StrategyFactory + log("Registering FUSDEVStrategy in FlowYieldVaults factory...") + result = _executeTransactionFile( + "../transactions/flow-yield-vaults/admin/add_strategy_composer.cdc", + [fusdEvStrategyIdentifier, composerIdentifier, issuerStoragePath], + [adminAccount] + ) + Test.expect(result, Test.beSucceeded()) + + // Grant beta access to all user accounts + log("Granting beta access to WFLOW/PYUSD0 user...") + result = _executeTransactionFile( + "../transactions/flow-yield-vaults/admin/grant_beta.cdc", + [], + [adminAccount, flowUser] + ) + Test.expect(result, Test.beSucceeded()) + + log("Granting beta access to WBTC/WETH user (0x68da18f20e98a7b6)...") + result = _executeTransactionFile( + "../transactions/flow-yield-vaults/admin/grant_beta.cdc", + [], + [adminAccount, wbtcUser] + ) + Test.expect(result, Test.beSucceeded()) + + // Provision extra FLOW to flowUser so that testDepositToFUSDEVYieldVault_WFLOW has enough balance. + // flowUser starts with ~11 FLOW; the create uses 10.0, leaving ~1 FLOW — not enough for a 5.0 deposit. + log("Provisioning 20.0 FLOW to WFLOW user from FlowToken contract account...") + result = _executeTransactionFile( + "../transactions/flow-token/transfer_flow.cdc", + [flowUser.address, 20.0], + [flowTokenAccount] + ) + Test.expect(result, Test.beSucceeded()) + + // Provision WETH and WBTC for the WBTC/WETH user. + // The COA at 0x000000000000000000000002b87c966bc00bc2c4 holds ~12 WETH on mainnet. + log("Bridging 2 WETH from COA to Cadence and swapping 0.1 WETH → WBTC for WBTC/WETH user...") + + // Bridge 2 WETH (2_000_000_000_000_000_000 at 18 decimals) from COA to Cadence. + let bridgeResult = _executeTransactionFile( + "../../lib/FlowALP/FlowActions/cadence/tests/transactions/bridge/bridge_tokens_from_evm.cdc", + [wethVaultIdentifier, 2000000000000000000 as UInt256], + [wbtcUser] + ) + Test.expect(bridgeResult, Test.beSucceeded()) + + // Swap 0.1 WETH → WBTC via UniV3 WETH/WBTC pool (fee 3000). + let swapResult = _executeTransactionFile( + "transactions/provision_wbtc_from_weth.cdc", + [ + "0xca6d7Bb03334bBf135902e1d919a5feccb461632", // UniV3 factory + "0xeEDC6Ff75e1b10B903D9013c358e446a73d35341", // UniV3 router + "0x370A8DF17742867a44e56223EC20D82092242C85", // UniV3 quoter + wethEVMAddress, + wbtcEVMAddress, + 3000 as UInt32, + 0.1 as UFix64 + ], + [wbtcUser] + ) + Test.expect(swapResult, Test.beSucceeded()) + + log("==== Setup Complete ====") +} + +/* ========================================================= + WFLOW (FlowToken) collateral lifecycle + ========================================================= */ + +access(all) fun testCreateFUSDEVYieldVault_WFLOW() { + log("Creating FUSDEVStrategy yield vault with 10.0 FLOW...") + let result = _executeTransactionFile( + "../transactions/flow-yield-vaults/create_yield_vault.cdc", + [fusdEvStrategyIdentifier, flowVaultIdentifier, 10.0], + [flowUser] + ) + Test.expect(result, Test.beSucceeded()) + + flowVaultID = _latestVaultID(flowUser) + log("Created WFLOW vault ID: ".concat(flowVaultID.toString())) + + let bal = _executeScript("../scripts/flow-yield-vaults/get_yield_vault_balance.cdc", [flowUser.address, flowVaultID]) + Test.expect(bal, Test.beSucceeded()) + let balance = bal.returnValue! as! UFix64? + Test.assert(balance != nil && balance! > 0.0, message: "Expected positive balance after create (WFLOW)") + log("WFLOW vault balance after create: ".concat(balance!.toString())) +} + +access(all) fun testDepositToFUSDEVYieldVault_WFLOW() { + let before = (_executeScript("../scripts/flow-yield-vaults/get_yield_vault_balance.cdc", [flowUser.address, flowVaultID]).returnValue! as! UFix64?) ?? 0.0 + let depositAmount: UFix64 = 5.0 + log("Depositing 5.0 FLOW to vault ".concat(flowVaultID.toString()).concat("...")) + Test.expect( + _executeTransactionFile("../transactions/flow-yield-vaults/deposit_to_yield_vault.cdc", [flowVaultID, depositAmount], [flowUser]), + Test.beSucceeded() + ) + let after = (_executeScript("../scripts/flow-yield-vaults/get_yield_vault_balance.cdc", [flowUser.address, flowVaultID]).returnValue! as! UFix64?)! + Test.assert(equalAmounts(a: after, b: before + depositAmount, tolerance: 0.1), + message: "WFLOW deposit: expected ~".concat((before + depositAmount).toString()).concat(", got ").concat(after.toString())) + log("WFLOW vault balance after deposit: ".concat(after.toString())) +} + +access(all) fun testWithdrawFromFUSDEVYieldVault_WFLOW() { + let before = (_executeScript("../scripts/flow-yield-vaults/get_yield_vault_balance.cdc", [flowUser.address, flowVaultID]).returnValue! as! UFix64?) ?? 0.0 + let withdrawAmount: UFix64 = 3.0 + log("Withdrawing 3.0 FLOW from vault ".concat(flowVaultID.toString()).concat("...")) + Test.expect( + _executeTransactionFile("../transactions/flow-yield-vaults/withdraw_from_yield_vault.cdc", [flowVaultID, withdrawAmount], [flowUser]), + Test.beSucceeded() + ) + let after = (_executeScript("../scripts/flow-yield-vaults/get_yield_vault_balance.cdc", [flowUser.address, flowVaultID]).returnValue! as! UFix64?)! + Test.assert(equalAmounts(a: after, b: before - withdrawAmount, tolerance: 0.1), + message: "WFLOW withdraw: expected ~".concat((before - withdrawAmount).toString()).concat(", got ").concat(after.toString())) + log("WFLOW vault balance after withdrawal: ".concat(after.toString())) +} + +access(all) fun testCloseFUSDEVYieldVault_WFLOW() { + let vaultBalBefore = (_executeScript("../scripts/flow-yield-vaults/get_yield_vault_balance.cdc", [flowUser.address, flowVaultID]).returnValue! as! UFix64?) ?? 0.0 + log("Closing WFLOW vault ".concat(flowVaultID.toString()).concat(" (balance: ").concat(vaultBalBefore.toString()).concat(")...")) + Test.expect( + _executeTransactionFile("../transactions/flow-yield-vaults/close_yield_vault.cdc", [flowVaultID], [flowUser]), + Test.beSucceeded() + ) + let vaultBalAfter = _executeScript("../scripts/flow-yield-vaults/get_yield_vault_balance.cdc", [flowUser.address, flowVaultID]) + Test.expect(vaultBalAfter, Test.beSucceeded()) + Test.assert(vaultBalAfter.returnValue == nil, message: "WFLOW vault should no longer exist after close") + log("WFLOW yield vault closed successfully") +} + +/* ========================================================= + WBTC collateral lifecycle + ========================================================= */ + +access(all) fun testCreateFUSDEVYieldVault_WBTC() { + log("Creating FUSDEVStrategy yield vault with 0.0001 WBTC...") + let result = _executeTransactionFile( + "../transactions/flow-yield-vaults/create_yield_vault.cdc", + [fusdEvStrategyIdentifier, wbtcVaultIdentifier, 0.0001], + [wbtcUser] + ) + Test.expect(result, Test.beSucceeded()) + + wbtcVaultID = _latestVaultID(wbtcUser) + log("Created WBTC vault ID: ".concat(wbtcVaultID.toString())) + + let bal = _executeScript("../scripts/flow-yield-vaults/get_yield_vault_balance.cdc", [wbtcUser.address, wbtcVaultID]) + Test.expect(bal, Test.beSucceeded()) + let balance = bal.returnValue! as! UFix64? + Test.assert(balance != nil && balance! > 0.0, message: "Expected positive balance after create (WBTC)") + log("WBTC vault balance after create: ".concat(balance!.toString())) +} + +access(all) fun testDepositToFUSDEVYieldVault_WBTC() { + let before = (_executeScript("../scripts/flow-yield-vaults/get_yield_vault_balance.cdc", [wbtcUser.address, wbtcVaultID]).returnValue! as! UFix64?) ?? 0.0 + let depositAmount: UFix64 = 0.00005 + log("Depositing 0.00005 WBTC to vault ".concat(wbtcVaultID.toString()).concat("...")) + Test.expect( + _executeTransactionFile("../transactions/flow-yield-vaults/deposit_to_yield_vault.cdc", [wbtcVaultID, depositAmount], [wbtcUser]), + Test.beSucceeded() + ) + let after = (_executeScript("../scripts/flow-yield-vaults/get_yield_vault_balance.cdc", [wbtcUser.address, wbtcVaultID]).returnValue! as! UFix64?)! + Test.assert(equalAmounts(a: after, b: before + depositAmount, tolerance: 0.000005), + message: "WBTC deposit: expected ~".concat((before + depositAmount).toString()).concat(", got ").concat(after.toString())) + log("WBTC vault balance after deposit: ".concat(after.toString())) +} + +access(all) fun testWithdrawFromFUSDEVYieldVault_WBTC() { + let before = (_executeScript("../scripts/flow-yield-vaults/get_yield_vault_balance.cdc", [wbtcUser.address, wbtcVaultID]).returnValue! as! UFix64?) ?? 0.0 + let withdrawAmount: UFix64 = 0.00003 + log("Withdrawing 0.00003 WBTC from vault ".concat(wbtcVaultID.toString()).concat("...")) + Test.expect( + _executeTransactionFile("../transactions/flow-yield-vaults/withdraw_from_yield_vault.cdc", [wbtcVaultID, withdrawAmount], [wbtcUser]), + Test.beSucceeded() + ) + let after = (_executeScript("../scripts/flow-yield-vaults/get_yield_vault_balance.cdc", [wbtcUser.address, wbtcVaultID]).returnValue! as! UFix64?)! + Test.assert(equalAmounts(a: after, b: before - withdrawAmount, tolerance: 0.000005), + message: "WBTC withdraw: expected ~".concat((before - withdrawAmount).toString()).concat(", got ").concat(after.toString())) + log("WBTC vault balance after withdrawal: ".concat(after.toString())) +} + +access(all) fun testCloseFUSDEVYieldVault_WBTC() { + let vaultBalBefore = (_executeScript("../scripts/flow-yield-vaults/get_yield_vault_balance.cdc", [wbtcUser.address, wbtcVaultID]).returnValue! as! UFix64?) ?? 0.0 + log("Closing WBTC vault ".concat(wbtcVaultID.toString()).concat(" (balance: ").concat(vaultBalBefore.toString()).concat(")...")) + Test.expect( + _executeTransactionFile("../transactions/flow-yield-vaults/close_yield_vault.cdc", [wbtcVaultID], [wbtcUser]), + Test.beSucceeded() + ) + let vaultBalAfter = _executeScript("../scripts/flow-yield-vaults/get_yield_vault_balance.cdc", [wbtcUser.address, wbtcVaultID]) + Test.expect(vaultBalAfter, Test.beSucceeded()) + Test.assert(vaultBalAfter.returnValue == nil, message: "WBTC vault should no longer exist after close") + log("WBTC yield vault closed successfully") +} + +/* ========================================================= + WETH collateral lifecycle + ========================================================= */ + +access(all) fun testCreateFUSDEVYieldVault_WETH() { + log("Creating FUSDEVStrategy yield vault with 0.001 WETH...") + let result = _executeTransactionFile( + "../transactions/flow-yield-vaults/create_yield_vault.cdc", + [fusdEvStrategyIdentifier, wethVaultIdentifier, 0.001], + [wethUser] + ) + Test.expect(result, Test.beSucceeded()) + + wethVaultID = _latestVaultID(wethUser) + log("Created WETH vault ID: ".concat(wethVaultID.toString())) + + let bal = _executeScript("../scripts/flow-yield-vaults/get_yield_vault_balance.cdc", [wethUser.address, wethVaultID]) + Test.expect(bal, Test.beSucceeded()) + let balance = bal.returnValue! as! UFix64? + Test.assert(balance != nil && balance! > 0.0, message: "Expected positive balance after create (WETH)") + log("WETH vault balance after create: ".concat(balance!.toString())) +} + +access(all) fun testDepositToFUSDEVYieldVault_WETH() { + let before = (_executeScript("../scripts/flow-yield-vaults/get_yield_vault_balance.cdc", [wethUser.address, wethVaultID]).returnValue! as! UFix64?) ?? 0.0 + let depositAmount: UFix64 = 0.0005 + log("Depositing 0.0005 WETH to vault ".concat(wethVaultID.toString()).concat("...")) + Test.expect( + _executeTransactionFile("../transactions/flow-yield-vaults/deposit_to_yield_vault.cdc", [wethVaultID, depositAmount], [wethUser]), + Test.beSucceeded() + ) + let after = (_executeScript("../scripts/flow-yield-vaults/get_yield_vault_balance.cdc", [wethUser.address, wethVaultID]).returnValue! as! UFix64?)! + Test.assert(equalAmounts(a: after, b: before + depositAmount, tolerance: 0.00005), + message: "WETH deposit: expected ~".concat((before + depositAmount).toString()).concat(", got ").concat(after.toString())) + log("WETH vault balance after deposit: ".concat(after.toString())) +} + +access(all) fun testWithdrawFromFUSDEVYieldVault_WETH() { + let before = (_executeScript("../scripts/flow-yield-vaults/get_yield_vault_balance.cdc", [wethUser.address, wethVaultID]).returnValue! as! UFix64?) ?? 0.0 + let withdrawAmount: UFix64 = 0.0003 + log("Withdrawing 0.0003 WETH from vault ".concat(wethVaultID.toString()).concat("...")) + Test.expect( + _executeTransactionFile("../transactions/flow-yield-vaults/withdraw_from_yield_vault.cdc", [wethVaultID, withdrawAmount], [wethUser]), + Test.beSucceeded() + ) + let after = (_executeScript("../scripts/flow-yield-vaults/get_yield_vault_balance.cdc", [wethUser.address, wethVaultID]).returnValue! as! UFix64?)! + Test.assert(equalAmounts(a: after, b: before - withdrawAmount, tolerance: 0.00005), + message: "WETH withdraw: expected ~".concat((before - withdrawAmount).toString()).concat(", got ").concat(after.toString())) + log("WETH vault balance after withdrawal: ".concat(after.toString())) +} + +access(all) fun testCloseFUSDEVYieldVault_WETH() { + let vaultBalBefore = (_executeScript("../scripts/flow-yield-vaults/get_yield_vault_balance.cdc", [wethUser.address, wethVaultID]).returnValue! as! UFix64?) ?? 0.0 + log("Closing WETH vault ".concat(wethVaultID.toString()).concat(" (balance: ").concat(vaultBalBefore.toString()).concat(")...")) + Test.expect( + _executeTransactionFile("../transactions/flow-yield-vaults/close_yield_vault.cdc", [wethVaultID], [wethUser]), + Test.beSucceeded() + ) + let vaultBalAfter = _executeScript("../scripts/flow-yield-vaults/get_yield_vault_balance.cdc", [wethUser.address, wethVaultID]) + Test.expect(vaultBalAfter, Test.beSucceeded()) + Test.assert(vaultBalAfter.returnValue == nil, message: "WETH vault should no longer exist after close") + log("WETH yield vault closed successfully") +} + +/* ========================================================= + Negative tests + ========================================================= */ + +/// PYUSD0 is the underlying asset of FUSDEV — the strategy composer has no collateral config for +/// it, so attempting to create a vault with PYUSD0 as collateral must be rejected. +access(all) fun testCannotCreateYieldVaultWithPYUSD0AsCollateral() { + log("Attempting to create FUSDEVStrategy vault with PYUSD0 (underlying asset) as collateral — expecting failure...") + let result = _executeTransactionFile( + "../transactions/flow-yield-vaults/create_yield_vault.cdc", + [fusdEvStrategyIdentifier, pyusd0VaultIdentifier, 1.0], + [flowUser] + ) + Test.expect(result, Test.beFailed()) + log("Correctly rejected PYUSD0 as collateral") +} + +/// Depositing the wrong token type into an existing YieldVault must be rejected. +/// Here wethUser owns both WETH and WBTC (set up in setup()). +/// We create a fresh WETH vault, then attempt to deposit WBTC into it — the strategy +/// pre-condition should panic on the type mismatch. +access(all) fun testCannotDepositWrongTokenToYieldVault() { + log("Creating a fresh WETH vault for wrong-token deposit test...") + let createResult = _executeTransactionFile( + "../transactions/flow-yield-vaults/create_yield_vault.cdc", + [fusdEvStrategyIdentifier, wethVaultIdentifier, 0.001], + [wethUser] + ) + Test.expect(createResult, Test.beSucceeded()) + let freshWethVaultID = _latestVaultID(wethUser) + log("Created WETH vault ID: ".concat(freshWethVaultID.toString()).concat(" — now attempting to deposit WBTC into it...")) + + // Attempt to deposit WBTC (wrong type) into the WETH vault — must fail + let depositResult = _executeTransactionFile( + "transactions/deposit_wrong_token.cdc", + [freshWethVaultID, wbtcVaultIdentifier, 0.00001], + [wethUser] + ) + Test.expect(depositResult, Test.beFailed()) + log("Correctly rejected wrong-token deposit (WBTC into WETH vault)") +} diff --git a/cadence/tests/transactions/deposit_wrong_token.cdc b/cadence/tests/transactions/deposit_wrong_token.cdc new file mode 100644 index 00000000..eacbe0a4 --- /dev/null +++ b/cadence/tests/transactions/deposit_wrong_token.cdc @@ -0,0 +1,48 @@ +import "FungibleToken" +import "FungibleTokenMetadataViews" + +import "FlowYieldVaults" +import "FlowYieldVaultsClosedBeta" + +/// Test-only transaction: attempts to deposit a token of the wrong type into an existing YieldVault. +/// The strategy's pre-condition should reject the mismatched vault type and cause this to fail. +/// +/// @param vaultID: The YieldVault to deposit into +/// @param wrongTokenTypeIdentifier: Type identifier of the wrong token to deposit +/// @param amount: Amount to withdraw from the signer's storage and attempt to deposit +/// +transaction(vaultID: UInt64, wrongTokenTypeIdentifier: String, amount: UFix64) { + let manager: &FlowYieldVaults.YieldVaultManager + let depositVault: @{FungibleToken.Vault} + let betaRef: auth(FlowYieldVaultsClosedBeta.Beta) &FlowYieldVaultsClosedBeta.BetaBadge + + prepare(signer: auth(BorrowValue, CopyValue) &Account) { + let betaCap = signer.storage.copy>( + from: FlowYieldVaultsClosedBeta.UserBetaCapStoragePath + ) ?? panic("Signer does not have a BetaBadge") + self.betaRef = betaCap.borrow() ?? panic("BetaBadge capability is invalid") + + self.manager = signer.storage.borrow<&FlowYieldVaults.YieldVaultManager>( + from: FlowYieldVaults.YieldVaultManagerStoragePath + ) ?? panic("Signer does not have a YieldVaultManager") + + let wrongType = CompositeType(wrongTokenTypeIdentifier) + ?? panic("Invalid type identifier \(wrongTokenTypeIdentifier)") + let tokenContract = getAccount(wrongType.address!).contracts.borrow<&{FungibleToken}>(name: wrongType.contractName!) + ?? panic("Type \(wrongTokenTypeIdentifier) is not a FungibleToken contract") + let vaultData = tokenContract.resolveContractView( + resourceType: wrongType, + viewType: Type() + ) as? FungibleTokenMetadataViews.FTVaultData + ?? panic("No FTVaultData for type \(wrongTokenTypeIdentifier)") + let sourceVault = signer.storage.borrow( + from: vaultData.storagePath + ) ?? panic("Signer has no vault of type \(wrongTokenTypeIdentifier) at path \(vaultData.storagePath)") + + self.depositVault <- sourceVault.withdraw(amount: amount) + } + + execute { + self.manager.depositToYieldVault(betaRef: self.betaRef, vaultID, from: <-self.depositVault) + } +} diff --git a/cadence/tests/transactions/provision_wbtc_from_weth.cdc b/cadence/tests/transactions/provision_wbtc_from_weth.cdc new file mode 100644 index 00000000..0f9289d9 --- /dev/null +++ b/cadence/tests/transactions/provision_wbtc_from_weth.cdc @@ -0,0 +1,96 @@ +import "FungibleToken" +import "FungibleTokenMetadataViews" +import "EVM" +import "UniswapV3SwapConnectors" +import "FlowEVMBridgeConfig" + +/// Swap WETH Cadence tokens → WBTC Cadence tokens via the UniV3 WETH/WBTC pool. +/// Sets up the WBTC Cadence vault in signer's storage if not present. +/// +/// @param factoryAddr: UniswapV3 factory EVM address (hex, no 0x prefix) +/// @param routerAddr: UniswapV3 router EVM address +/// @param quoterAddr: UniswapV3 quoter EVM address +/// @param wethEvmAddr: WETH EVM contract address +/// @param wbtcEvmAddr: WBTC (cbBTC) EVM contract address +/// @param fee: UniV3 pool fee tier (e.g. 3000) +/// @param wethAmount: Amount of WETH (Cadence UFix64) to swap for WBTC +/// +transaction( + factoryAddr: String, + routerAddr: String, + quoterAddr: String, + wethEvmAddr: String, + wbtcEvmAddr: String, + fee: UInt32, + wethAmount: UFix64 +) { + prepare(signer: auth(Storage, BorrowValue, IssueStorageCapabilityController, SaveValue, PublishCapability, UnpublishCapability) &Account) { + let coaCap = signer.capabilities.storage.issue(/storage/evm) + + let wethEVM = EVM.addressFromString(wethEvmAddr) + let wbtcEVM = EVM.addressFromString(wbtcEvmAddr) + + let wethType = FlowEVMBridgeConfig.getTypeAssociated(with: wethEVM) + ?? panic("WETH EVM address not registered in bridge config: ".concat(wethEvmAddr)) + let wbtcType = FlowEVMBridgeConfig.getTypeAssociated(with: wbtcEVM) + ?? panic("WBTC EVM address not registered in bridge config: ".concat(wbtcEvmAddr)) + + let swapper = UniswapV3SwapConnectors.Swapper( + factoryAddress: EVM.addressFromString(factoryAddr), + routerAddress: EVM.addressFromString(routerAddr), + quoterAddress: EVM.addressFromString(quoterAddr), + tokenPath: [wethEVM, wbtcEVM], + feePath: [fee], + inVault: wethType, + outVault: wbtcType, + coaCapability: coaCap, + uniqueID: nil + ) + + // Locate WETH vault via FTVaultData so we don't hard-code the storage path. + let wethVaultCompType = CompositeType(wethType.identifier) + ?? panic("Cannot construct CompositeType for WETH: ".concat(wethType.identifier)) + let wethContract = getAccount(wethVaultCompType.address!).contracts.borrow<&{FungibleToken}>(name: wethVaultCompType.contractName!) + ?? panic("Cannot borrow FungibleToken contract for WETH") + let wethVaultData = wethContract.resolveContractView( + resourceType: wethVaultCompType, + viewType: Type() + ) as? FungibleTokenMetadataViews.FTVaultData + ?? panic("Cannot resolve FTVaultData for WETH") + + let wethProvider = signer.storage.borrow( + from: wethVaultData.storagePath + ) ?? panic("No WETH vault in signer's storage at ".concat(wethVaultData.storagePath.toString())) + + let inVault <- wethProvider.withdraw(amount: wethAmount) + + // Swap WETH → WBTC (bridges to EVM, swaps, bridges back to Cadence). + let outVault <- swapper.swap(quote: nil, inVault: <-inVault) + log("Provisioned ".concat(outVault.balance.toString()).concat(" WBTC from ".concat(wethAmount.toString()).concat(" WETH"))) + + // Set up WBTC vault in signer's storage if missing. + let wbtcVaultCompType = CompositeType(wbtcType.identifier) + ?? panic("Cannot construct CompositeType for WBTC: ".concat(wbtcType.identifier)) + let wbtcContract = getAccount(wbtcVaultCompType.address!).contracts.borrow<&{FungibleToken}>(name: wbtcVaultCompType.contractName!) + ?? panic("Cannot borrow FungibleToken contract for WBTC") + let wbtcVaultData = wbtcContract.resolveContractView( + resourceType: wbtcVaultCompType, + viewType: Type() + ) as? FungibleTokenMetadataViews.FTVaultData + ?? panic("Cannot resolve FTVaultData for WBTC") + + if signer.storage.borrow<&{FungibleToken.Vault}>(from: wbtcVaultData.storagePath) == nil { + signer.storage.save(<-wbtcVaultData.createEmptyVault(), to: wbtcVaultData.storagePath) + signer.capabilities.unpublish(wbtcVaultData.receiverPath) + signer.capabilities.unpublish(wbtcVaultData.metadataPath) + let receiverCap = signer.capabilities.storage.issue<&{FungibleToken.Vault}>(wbtcVaultData.storagePath) + let metadataCap = signer.capabilities.storage.issue<&{FungibleToken.Vault}>(wbtcVaultData.storagePath) + signer.capabilities.publish(receiverCap, at: wbtcVaultData.receiverPath) + signer.capabilities.publish(metadataCap, at: wbtcVaultData.metadataPath) + } + + let receiver = signer.storage.borrow<&{FungibleToken.Receiver}>(from: wbtcVaultData.storagePath) + ?? panic("Cannot borrow WBTC vault receiver") + receiver.deposit(from: <-outVault) + } +} diff --git a/cadence/transactions/flow-yield-vaults/admin/upsert_strategy_config.cdc b/cadence/transactions/flow-yield-vaults/admin/upsert_strategy_config.cdc index 792c7031..d36da60b 100644 --- a/cadence/transactions/flow-yield-vaults/admin/upsert_strategy_config.cdc +++ b/cadence/transactions/flow-yield-vaults/admin/upsert_strategy_config.cdc @@ -45,16 +45,13 @@ transaction( return out } - let composerType = Type<@FlowYieldVaultsStrategiesV2.MorphoERC4626StrategyComposer>() - if swapPath.length > 0 { - issuer.addOrUpdateCollateralConfig( - composer: composerType, + issuer.addOrUpdateMorphoCollateralConfig( strategyType: strategyType, collateralVaultType: tokenType, yieldTokenEVMAddress: yieldEVM, yieldToCollateralAddressPath: toEVM(swapPath), - yieldToCollateralFeePath: fees + yieldToCollateralFeePath: fees ) } }