Skip to content

Commit f9daf70

Browse files
authored
Merge pull request #182 from onflow/nialexsan/dust-debug-flowalp
close position from strategies
2 parents ae6ec8f + 8ea43dd commit f9daf70

23 files changed

Lines changed: 1316 additions & 266 deletions

cadence/contracts/FlowYieldVaults.cdc

Lines changed: 25 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -153,6 +153,10 @@ access(all) contract FlowYieldVaults {
153153
"Invalid Vault returns - requests \(ofToken.identifier) but returned \(result.getType().identifier)"
154154
}
155155
}
156+
/// Closes the underlying position by repaying all debt and returning all collateral.
157+
/// This method uses the AutoBalancer as a repayment source to swap yield tokens to debt tokens as needed.
158+
/// Returns a Vault containing all collateral including any dust residuals.
159+
access(FungibleToken.Withdraw) fun closePosition(collateralType: Type): @{FungibleToken.Vault}
156160
}
157161

158162
/// StrategyComposer
@@ -414,6 +418,23 @@ access(all) contract FlowYieldVaults {
414418

415419
return <- res
416420
}
421+
/// Closes the YieldVault by repaying all debt on the underlying position and returning all collateral.
422+
/// This method properly closes the FlowALP position by using the AutoBalancer to swap yield tokens
423+
/// to MOET for debt repayment, then returns all collateral including any dust residuals.
424+
access(FungibleToken.Withdraw) fun close(): @{FungibleToken.Vault} {
425+
let collateral <- self._borrowStrategy().closePosition(collateralType: self.vaultType)
426+
427+
emit WithdrawnFromYieldVault(
428+
id: self.uniqueID.id,
429+
strategyType: self.getStrategyType(),
430+
tokenType: collateral.getType().identifier,
431+
amount: collateral.balance,
432+
owner: self.owner?.address,
433+
toUUID: collateral.uuid
434+
)
435+
436+
return <- collateral
437+
}
417438
/// Returns an authorized reference to the encapsulated Strategy
418439
access(self) view fun _borrowStrategy(): auth(FungibleToken.Withdraw) &{Strategy} {
419440
return &self.strategy as auth(FungibleToken.Withdraw) &{Strategy}?
@@ -539,16 +560,17 @@ access(all) contract FlowYieldVaults {
539560
let yieldVault = (&self.yieldVaults[id] as auth(FungibleToken.Withdraw) &YieldVault?)!
540561
return <- yieldVault.withdraw(amount: amount)
541562
}
542-
/// Withdraws and returns all available funds from the specified YieldVault, destroying the YieldVault and access to any
543-
/// Strategy-related wiring with it
563+
/// Closes the YieldVault by repaying all debt and returning all collateral, then destroys the YieldVault.
564+
/// This properly closes the underlying FlowALP position by using the AutoBalancer to swap yield tokens
565+
/// to MOET for debt repayment, ensuring all collateral (including dust) is returned to the caller.
544566
access(FungibleToken.Withdraw) fun closeYieldVault(_ id: UInt64): @{FungibleToken.Vault} {
545567
pre {
546568
self.yieldVaults[id] != nil:
547569
"No YieldVault with ID \(id) found"
548570
}
549571

550572
let yieldVault <- self._withdrawYieldVault(id: id)
551-
let res <- yieldVault.withdraw(amount: yieldVault.getYieldVaultBalance())
573+
let res <- yieldVault.close()
552574
Burner.burn(<-yieldVault)
553575
return <-res
554576
}

cadence/contracts/FlowYieldVaultsAutoBalancers.cdc

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,20 @@ access(all) contract FlowYieldVaultsAutoBalancers {
4444
return self.account.capabilities.borrow<&DeFiActions.AutoBalancer>(publicPath)
4545
}
4646

47+
/// Creates a source from an AutoBalancer for external use (e.g., position close operations).
48+
/// This allows bypassing position topUpSource to avoid circular dependency issues.
49+
///
50+
/// @param id: The yield vault/AutoBalancer ID
51+
/// @return Source that can withdraw from the AutoBalancer, or nil if not found
52+
///
53+
access(account) fun createExternalSource(id: UInt64): {DeFiActions.Source}? {
54+
let storagePath = self.deriveAutoBalancerPath(id: id, storage: true) as! StoragePath
55+
if let autoBalancer = self.account.storage.borrow<auth(DeFiActions.Get) &DeFiActions.AutoBalancer>(from: storagePath) {
56+
return autoBalancer.createBalancerSource()
57+
}
58+
return nil
59+
}
60+
4761
/// Checks if an AutoBalancer has at least one active (Scheduled) transaction.
4862
/// Used by Supervisor to detect stuck yield vaults that need recovery.
4963
///

cadence/contracts/FlowYieldVaultsStrategiesV2.cdc

Lines changed: 230 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ access(all) contract FlowYieldVaultsStrategiesV2 {
4242
access(all) let univ3RouterEVMAddress: EVM.EVMAddress
4343
access(all) let univ3QuoterEVMAddress: EVM.EVMAddress
4444

45-
access(all) let config: {String: AnyStruct}
45+
access(contract) let config: {String: AnyStruct}
4646

4747
/// Canonical StoragePath where the StrategyComposerIssuer should be stored
4848
access(all) let IssuerStoragePath: StoragePath
@@ -72,19 +72,32 @@ access(all) contract FlowYieldVaultsStrategiesV2 {
7272
}
7373
}
7474

75-
/// This strategy uses FUSDEV vault
75+
/// This strategy uses FUSDEV vault (Morpho ERC4626).
76+
/// Deposits collateral into a single FlowALP position, borrowing MOET as debt.
77+
/// MOET is swapped to PYUSD0 and deposited into the Morpho FUSDEV ERC4626 vault.
78+
/// Each strategy instance holds exactly one collateral type and one debt type (MOET).
79+
/// PYUSD0 (the FUSDEV vault's underlying asset) cannot be used as collateral.
7680
access(all) resource FUSDEVStrategy : FlowYieldVaults.Strategy, DeFiActions.IdentifiableResource {
7781
/// An optional identifier allowing protocols to identify stacked connector operations by defining a protocol-
7882
/// specific Identifier to associated connectors on construction
7983
access(contract) var uniqueID: DeFiActions.UniqueIdentifier?
8084
access(self) let position: @FlowALPv0.Position
8185
access(self) var sink: {DeFiActions.Sink}
8286
access(self) var source: {DeFiActions.Source}
87+
/// Tracks whether the underlying FlowALP position has been closed. Once true,
88+
/// availableBalance() returns 0.0 to avoid panicking when the pool no longer
89+
/// holds the position (e.g. during YieldVault burnCallback after close).
90+
access(self) var positionClosed: Bool
8391

84-
init(id: DeFiActions.UniqueIdentifier, collateralType: Type, position: @FlowALPv0.Position) {
92+
init(
93+
id: DeFiActions.UniqueIdentifier,
94+
collateralType: Type,
95+
position: @FlowALPv0.Position
96+
) {
8597
self.uniqueID = id
8698
self.sink = position.createSink(type: collateralType)
8799
self.source = position.createSourceWithOptions(type: collateralType, pullFromTopUpSource: true)
100+
self.positionClosed = false
88101
self.position <-position
89102
}
90103

@@ -96,10 +109,16 @@ access(all) contract FlowYieldVaultsStrategiesV2 {
96109
}
97110
/// Returns the amount available for withdrawal via the inner Source
98111
access(all) fun availableBalance(ofToken: Type): UFix64 {
112+
if self.positionClosed { return 0.0 }
99113
return ofToken == self.source.getSourceType() ? self.source.minimumAvailable() : 0.0
100114
}
101-
/// Deposits up to the inner Sink's capacity from the provided authorized Vault reference
115+
/// Deposits up to the inner Sink's capacity from the provided authorized Vault reference.
116+
/// Only the single configured collateral type is accepted — one collateral type per position.
102117
access(all) fun deposit(from: auth(FungibleToken.Withdraw) &{FungibleToken.Vault}) {
118+
pre {
119+
from.getType() == self.sink.getSinkType():
120+
"FUSDEVStrategy position only accepts \(self.sink.getSinkType().identifier) as collateral, got \(from.getType().identifier)"
121+
}
103122
self.sink.depositCapacity(from: from)
104123
}
105124
/// Withdraws up to the max amount, returning the withdrawn Vault. If the requested token type is unsupported,
@@ -110,6 +129,118 @@ access(all) contract FlowYieldVaultsStrategiesV2 {
110129
}
111130
return <- self.source.withdrawAvailable(maxAmount: maxAmount)
112131
}
132+
/// Closes the underlying FlowALP position by preparing repayment funds and closing with them.
133+
///
134+
/// This method:
135+
/// 1. Calculates debt amount from position
136+
/// 2. Creates external yield token source from AutoBalancer
137+
/// 3. Swaps yield tokens → MOET via stored swapper
138+
/// 4. Closes position with prepared MOET vault
139+
///
140+
/// This approach eliminates circular dependencies by preparing all funds externally
141+
/// before calling the position's close method.
142+
///
143+
access(FungibleToken.Withdraw) fun closePosition(collateralType: Type): @{FungibleToken.Vault} {
144+
pre {
145+
self.isSupportedCollateralType(collateralType):
146+
"Unsupported collateral type \(collateralType.identifier)"
147+
}
148+
post {
149+
result.getType() == collateralType: "Withdraw Vault (\(result.getType().identifier)) is not of a requested collateral type (\(collateralType.identifier))"
150+
}
151+
152+
// Step 1: Get debt amounts - returns {Type: UFix64} dictionary
153+
let debtsByType = self.position.getTotalDebt()
154+
155+
// Enforce: one debt type per position
156+
assert(
157+
debtsByType.length <= 1,
158+
message: "FUSDEVStrategy position must have at most one debt type, found \(debtsByType.length)"
159+
)
160+
161+
// Step 2: Calculate total debt amount
162+
var totalDebtAmount: UFix64 = 0.0
163+
for debtAmount in debtsByType.values {
164+
totalDebtAmount = totalDebtAmount + debtAmount
165+
}
166+
167+
// Step 3: If no debt, close with empty sources array
168+
if totalDebtAmount == 0.0 {
169+
let resultVaults <- self.position.closePosition(
170+
repaymentSources: []
171+
)
172+
// With one collateral type and no debt the pool returns at most one vault.
173+
// Zero vaults is possible when the collateral balance is dust that rounds down
174+
// to zero (e.g. drawDownSink had no capacity, or token reserves were empty).
175+
assert(
176+
resultVaults.length <= 1,
177+
message: "Expected 0 or 1 collateral vault from closePosition, got \(resultVaults.length)"
178+
)
179+
// Zero vaults: dust collateral rounded down to zero — return an empty vault
180+
if resultVaults.length == 0 {
181+
destroy resultVaults
182+
self.positionClosed = true
183+
return <- DeFiActionsUtils.getEmptyVault(collateralType)
184+
}
185+
let collateralVault <- resultVaults.removeFirst()
186+
destroy resultVaults
187+
self.positionClosed = true
188+
return <- collateralVault
189+
}
190+
191+
// Step 4: Create external yield token source from AutoBalancer
192+
let yieldTokenSource = FlowYieldVaultsAutoBalancers.createExternalSource(id: self.id()!)
193+
?? panic("Could not create external source from AutoBalancer")
194+
195+
// Step 5: Retrieve yield→MOET swapper from contract config
196+
let swapperKey = FlowYieldVaultsStrategiesV2.getYieldToMoetSwapperConfigKey(self.uniqueID)!
197+
let yieldToMoetSwapper = FlowYieldVaultsStrategiesV2.config[swapperKey] as! {DeFiActions.Swapper}?
198+
?? panic("No yield→MOET swapper found for strategy \(self.id()!)")
199+
200+
// Step 6: Create a SwapSource that converts yield tokens to MOET when pulled by closePosition.
201+
// The pool will call source.withdrawAvailable(maxAmount: debtAmount) which internally uses
202+
// quoteIn(forDesired: debtAmount) to compute the exact yield token input needed.
203+
let moetSource = SwapConnectors.SwapSource(
204+
swapper: yieldToMoetSwapper,
205+
source: yieldTokenSource,
206+
uniqueID: self.copyID()
207+
)
208+
209+
// Step 7: Close position - pool pulls exactly the debt amount from moetSource
210+
let resultVaults <- self.position.closePosition(repaymentSources: [moetSource])
211+
212+
// With one collateral type and one debt type, the pool returns at most two vaults:
213+
// the collateral vault and optionally a MOET overpayment dust vault.
214+
assert(
215+
resultVaults.length >= 1 && resultVaults.length <= 2,
216+
message: "Expected 1 or 2 vaults from closePosition, got \(resultVaults.length)"
217+
)
218+
219+
var collateralVault <- resultVaults.removeFirst()
220+
assert(
221+
collateralVault.getType() == collateralType,
222+
message: "First vault returned from closePosition must be collateral (\(collateralType.identifier)), got \(collateralVault.getType().identifier)"
223+
)
224+
225+
// Handle any overpayment dust (MOET) returned as the second vault
226+
while resultVaults.length > 0 {
227+
let dustVault <- resultVaults.removeFirst()
228+
if dustVault.balance > 0.0 {
229+
if dustVault.getType() == collateralType {
230+
collateralVault.deposit(from: <-dustVault)
231+
} else {
232+
// @TODO implement swapping moet to collateral
233+
destroy dustVault
234+
}
235+
} else {
236+
destroy dustVault
237+
}
238+
}
239+
240+
destroy resultVaults
241+
self.positionClosed = true
242+
return <- collateralVault
243+
}
113244
/// Executed when a Strategy is burned, cleaning up the Strategy's stored AutoBalancer
114245
access(contract) fun burnCallback() {
115246
FlowYieldVaultsAutoBalancers._cleanupAutoBalancer(id: self.id()!)
@@ -301,9 +432,46 @@ access(all) contract FlowYieldVaultsStrategiesV2 {
301432
uniqueID: uniqueID
302433
)
303434

435+
// pullFromTopUpSource: false ensures Position maintains health buffer
436+
// This prevents Position from being pushed to minHealth (1.1) limit
437+
let positionSource = position.createSourceWithOptions(
438+
type: collateralType,
439+
pullFromTopUpSource: false // ← CONSERVATIVE: maintain safety buffer
440+
)
441+
442+
// Create Collateral -> Yield swapper (reverse of yieldToCollateralSwapper)
443+
// Allows AutoBalancer to pull collateral, swap to yield token
444+
let collateralToYieldSwapper = self._createCollateralToYieldSwapper(
445+
collateralConfig: collateralConfig,
446+
yieldTokenEVMAddress: tokens.yieldTokenEVMAddress,
447+
yieldTokenType: tokens.yieldTokenType,
448+
collateralType: collateralType,
449+
uniqueID: uniqueID
450+
)
451+
452+
// Create Position swap source for AutoBalancer deficit recovery
453+
// When AutoBalancer value drops below deposits, pulls collateral from Position
454+
let positionSwapSource = SwapConnectors.SwapSource(
455+
swapper: collateralToYieldSwapper,
456+
source: positionSource,
457+
uniqueID: uniqueID
458+
)
459+
304460
// Set AutoBalancer sink for overflow -> recollateralize
305461
balancerIO.autoBalancer.setSink(positionSwapSink, updateSinkID: true)
306462

463+
// Set AutoBalancer source for deficit recovery -> pull from Position
464+
balancerIO.autoBalancer.setSource(positionSwapSource, updateSourceID: true)
465+
466+
// Store yield→MOET swapper in contract config for later access during closePosition
467+
let yieldToMoetSwapperKey = FlowYieldVaultsStrategiesV2.getYieldToMoetSwapperConfigKey(uniqueID)!
468+
FlowYieldVaultsStrategiesV2.config[yieldToMoetSwapperKey] = yieldToMoetSwapper
469+
470+
// @TODO implement moet to collateral swapper
471+
// let moetToCollateralSwapperKey = FlowYieldVaultsStrategiesV2.getMoetToCollateralSwapperConfigKey(uniqueID)
472+
//
473+
// FlowYieldVaultsStrategiesV2.config[moetToCollateralSwapperKey] = moetToCollateralSwapper
474+
//
307475
switch type {
308476
case Type<@FUSDEVStrategy>():
309477
return <-create FUSDEVStrategy(
@@ -514,6 +682,16 @@ access(all) contract FlowYieldVaultsStrategiesV2 {
514682
}
515683
}
516684

685+
/// @TODO
686+
/// implement moet to collateral swapper
687+
// access(self) fun _createMoetToCollateralSwapper(
688+
// strategyType: Type,
689+
// tokens: FlowYieldVaultsStrategiesV2.TokenBundle,
690+
// uniqueID: DeFiActions.UniqueIdentifier
691+
// ): SwapConnectors.MultiSwapper {
692+
// // Direct MOET -> underlying via AMM
693+
// }
694+
517695
access(self) fun _initAutoBalancerAndIO(
518696
oracle: {DeFiActions.PriceOracle},
519697
yieldTokenType: Type,
@@ -595,6 +773,40 @@ access(all) contract FlowYieldVaultsStrategiesV2 {
595773
uniqueID: uniqueID
596774
)
597775
}
776+
777+
/// Creates a Collateral -> Yield token swapper using UniswapV3
778+
/// This is the REVERSE of _createYieldToCollateralSwapper
779+
/// Used by AutoBalancer to pull collateral from Position and swap to yield tokens
780+
///
781+
access(self) fun _createCollateralToYieldSwapper(
782+
collateralConfig: FlowYieldVaultsStrategiesV2.CollateralConfig,
783+
yieldTokenEVMAddress: EVM.EVMAddress,
784+
yieldTokenType: Type,
785+
collateralType: Type,
786+
uniqueID: DeFiActions.UniqueIdentifier
787+
): UniswapV3SwapConnectors.Swapper {
788+
// Reverse the swap path: collateral -> yield (opposite of yield -> collateral)
789+
let forwardPath = collateralConfig.yieldToCollateralUniV3AddressPath
790+
let reversedTokenPath = forwardPath.reverse()
791+
792+
// Reverse the fee path as well
793+
let forwardFees = collateralConfig.yieldToCollateralUniV3FeePath
794+
let reversedFeePath = forwardFees.reverse()
795+
796+
// Verify the reversed path starts with collateral (ends with yield)
797+
assert(
798+
reversedTokenPath[reversedTokenPath.length - 1].equals(yieldTokenEVMAddress),
799+
message: "Reversed path must end with yield token \(yieldTokenEVMAddress.toString())"
800+
)
801+
802+
return self._createUniV3Swapper(
803+
tokenPath: reversedTokenPath,
804+
feePath: reversedFeePath,
805+
inVault: collateralType, // ← Input is collateral
806+
outVault: yieldTokenType, // ← Output is yield token
807+
uniqueID: uniqueID
808+
)
809+
}
598810
}
599811

600812
access(all) entitlement Configure
@@ -810,6 +1022,20 @@ access(all) contract FlowYieldVaultsStrategiesV2 {
8101022
)
8111023
}
8121024

1025+
access(self) view fun getYieldToMoetSwapperConfigKey(_ uniqueID: DeFiActions.UniqueIdentifier?): String {
1026+
pre {
1027+
uniqueID != nil: "Missing UniqueIdentifier for swapper config key"
1028+
}
1029+
return "yieldToMoetSwapper_\(uniqueID!.id.toString())"
1030+
}
1031+
1032+
access(self) view fun getMoetToCollateralSwapperConfigKey(_ uniqueID: DeFiActions.UniqueIdentifier?): String {
1033+
pre {
1034+
uniqueID != nil: "Missing UniqueIdentifier for swapper config key"
1035+
}
1036+
return "moetToCollateralSwapper_\(uniqueID!.id.toString())"
1037+
}
1038+
8131039
init(
8141040
univ3FactoryEVMAddress: String,
8151041
univ3RouterEVMAddress: String,

0 commit comments

Comments
 (0)