Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
36 commits
Select commit Hold shift + click to select a range
44854d6
rebalance test edge cases
nialexsan Feb 23, 2026
d4c07bb
Merge branch 'main' into nialexsan/dust-debug-flowalp
nialexsan Feb 24, 2026
87f1fe4
close position
nialexsan Feb 25, 2026
94e6013
mock swap rounding
nialexsan Feb 25, 2026
79f79e4
remove doc
nialexsan Feb 25, 2026
1e6acc3
update ref
nialexsan Feb 25, 2026
21c6c5a
close position in strategy
nialexsan Feb 25, 2026
190e1c3
remove comments
nialexsan Feb 25, 2026
8e3c733
Merge branch 'main' into nialexsan/dust-debug-flowalp
nialexsan Feb 25, 2026
341461d
fix closing
nialexsan Feb 26, 2026
d154cea
switch to upgradeable change
nialexsan Feb 26, 2026
226c86a
add autobalancer
nialexsan Feb 26, 2026
6a6b85c
fix tracer strategy
nialexsan Feb 26, 2026
934319a
remove unused method
nialexsan Feb 26, 2026
1c61628
Apply suggestion from @nialexsan
nialexsan Feb 26, 2026
41b24de
update ref
nialexsan Feb 26, 2026
c26627c
sync implementation with FlowALP
nialexsan Mar 1, 2026
a92ce68
fix deposit rate
nialexsan Mar 1, 2026
884e899
split PMStrategies test
nialexsan Mar 1, 2026
3452d17
update FlowALP ref
nialexsan Mar 5, 2026
a763eac
revert ci/cd
nialexsan Mar 5, 2026
c5d2c55
fix: adapt strategies to FlowALPv0 API changes from submodule update
holyfuchs Mar 5, 2026
cbfbd04
address comments
nialexsan Mar 5, 2026
4d7b359
fix contract
nialexsan Mar 5, 2026
e2787e0
Fix zero-debt close path state tracking
liobrasil Mar 6, 2026
ca91632
Apply suggestion from @nialexsan
nialexsan Mar 6, 2026
9b105b1
chore: mark strategy config key helpers as view
liobrasil Mar 6, 2026
27edd22
fix: publish receiver capabilities in yield vault txns
liobrasil Mar 6, 2026
268bbaa
address PR comments
nialexsan Mar 7, 2026
2d8788b
combine tests
nialexsan Mar 7, 2026
6a2915e
remove duplicated methods
nialexsan Mar 7, 2026
9cf0944
address strategy comments
nialexsan Mar 8, 2026
49b0a17
assert first vault is collateral[C
nialexsan Mar 8, 2026
f88fff4
tweaks
nialexsan Mar 8, 2026
f74da70
tweak assertion
nialexsan Mar 8, 2026
068457d
tweak mock
nialexsan Mar 8, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 25 additions & 3 deletions cadence/contracts/FlowYieldVaults.cdc
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,10 @@ access(all) contract FlowYieldVaults {
"Invalid Vault returns - requests \(ofToken.identifier) but returned \(result.getType().identifier)"
}
}
/// Closes the underlying position by repaying all debt and returning all collateral.
/// This method uses the AutoBalancer as a repayment source to swap yield tokens to debt tokens as needed.
/// Returns a Vault containing all collateral including any dust residuals.
access(FungibleToken.Withdraw) fun closePosition(collateralType: Type): @{FungibleToken.Vault}
}

/// StrategyComposer
Expand Down Expand Up @@ -340,6 +344,23 @@ access(all) contract FlowYieldVaults {

return <- res
}
/// Closes the YieldVault by repaying all debt on the underlying position and returning all collateral.
/// This method properly closes the FlowALP position by using the AutoBalancer to swap yield tokens
/// to MOET for debt repayment, then returns all collateral including any dust residuals.
access(FungibleToken.Withdraw) fun close(): @{FungibleToken.Vault} {
let collateral <- self._borrowStrategy().closePosition(collateralType: self.vaultType)

emit WithdrawnFromYieldVault(
id: self.uniqueID.id,
strategyType: self.getStrategyType(),
tokenType: collateral.getType().identifier,
amount: collateral.balance,
owner: self.owner?.address,
toUUID: collateral.uuid
)

return <- collateral
}
/// Returns an authorized reference to the encapsulated Strategy
access(self) view fun _borrowStrategy(): auth(FungibleToken.Withdraw) &{Strategy} {
return &self.strategy as auth(FungibleToken.Withdraw) &{Strategy}?
Expand Down Expand Up @@ -465,16 +486,17 @@ access(all) contract FlowYieldVaults {
let yieldVault = (&self.yieldVaults[id] as auth(FungibleToken.Withdraw) &YieldVault?)!
return <- yieldVault.withdraw(amount: amount)
}
/// Withdraws and returns all available funds from the specified YieldVault, destroying the YieldVault and access to any
/// Strategy-related wiring with it
/// Closes the YieldVault by repaying all debt and returning all collateral, then destroys the YieldVault.
/// This properly closes the underlying FlowALP position by using the AutoBalancer to swap yield tokens
/// to MOET for debt repayment, ensuring all collateral (including dust) is returned to the caller.
access(FungibleToken.Withdraw) fun closeYieldVault(_ id: UInt64): @{FungibleToken.Vault} {
pre {
self.yieldVaults[id] != nil:
"No YieldVault with ID \(id) found"
}

let yieldVault <- self._withdrawYieldVault(id: id)
let res <- yieldVault.withdraw(amount: yieldVault.getYieldVaultBalance())
let res <- yieldVault.close()
Burner.burn(<-yieldVault)
return <-res
}
Expand Down
14 changes: 14 additions & 0 deletions cadence/contracts/FlowYieldVaultsAutoBalancers.cdc
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,20 @@ access(all) contract FlowYieldVaultsAutoBalancers {
return self.account.capabilities.borrow<&DeFiActions.AutoBalancer>(publicPath)
}

/// Creates a source from an AutoBalancer for external use (e.g., position close operations).
/// This allows bypassing position topUpSource to avoid circular dependency issues.
///
/// @param id: The yield vault/AutoBalancer ID
/// @return Source that can withdraw from the AutoBalancer, or nil if not found
///
access(account) fun createExternalSource(id: UInt64): {DeFiActions.Source}? {
let storagePath = self.deriveAutoBalancerPath(id: id, storage: true) as! StoragePath
if let autoBalancer = self.account.storage.borrow<auth(DeFiActions.Get) &DeFiActions.AutoBalancer>(from: storagePath) {
return autoBalancer.createBalancerSource()
}
return nil
}

/// Checks if an AutoBalancer has at least one active (Scheduled) transaction.
/// Used by Supervisor to detect stuck yield vaults that need recovery.
///
Expand Down
234 changes: 230 additions & 4 deletions cadence/contracts/FlowYieldVaultsStrategiesV2.cdc
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ access(all) contract FlowYieldVaultsStrategiesV2 {
access(all) let univ3RouterEVMAddress: EVM.EVMAddress
access(all) let univ3QuoterEVMAddress: EVM.EVMAddress

access(all) let config: {String: AnyStruct}
access(contract) let config: {String: AnyStruct}

/// Canonical StoragePath where the StrategyComposerIssuer should be stored
access(all) let IssuerStoragePath: StoragePath
Expand Down Expand Up @@ -72,19 +72,32 @@ access(all) contract FlowYieldVaultsStrategiesV2 {
}
}

/// This strategy uses FUSDEV vault
/// This strategy uses FUSDEV vault (Morpho ERC4626).
/// Deposits collateral into a single FlowALP position, borrowing MOET as debt.
/// MOET is swapped to PYUSD0 and deposited into the Morpho FUSDEV ERC4626 vault.
/// Each strategy instance holds exactly one collateral type and one debt type (MOET).
/// PYUSD0 (the FUSDEV vault's underlying asset) cannot be used as collateral.
access(all) resource FUSDEVStrategy : FlowYieldVaults.Strategy, DeFiActions.IdentifiableResource {
/// An optional identifier allowing protocols to identify stacked connector operations by defining a protocol-
/// specific Identifier to associated connectors on construction
access(contract) var uniqueID: DeFiActions.UniqueIdentifier?
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, collateralType: Type, position: @FlowALPv0.Position) {
init(
id: DeFiActions.UniqueIdentifier,
collateralType: Type,
position: @FlowALPv0.Position
) {
self.uniqueID = id
self.sink = position.createSink(type: collateralType)
self.source = position.createSourceWithOptions(type: collateralType, pullFromTopUpSource: true)
self.positionClosed = false
self.position <-position
}

Expand All @@ -96,10 +109,16 @@ 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 }
return ofToken == self.source.getSourceType() ? self.source.minimumAvailable() : 0.0
}
/// Deposits up to the inner Sink's capacity from the provided authorized Vault reference
/// 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():
"FUSDEVStrategy position only accepts \(self.sink.getSinkType().identifier) as collateral, got \(from.getType().identifier)"
}
self.sink.depositCapacity(from: from)
}
/// Withdraws up to the max amount, returning the withdrawn Vault. If the requested token type is unsupported,
Expand All @@ -110,6 +129,118 @@ access(all) contract FlowYieldVaultsStrategiesV2 {
}
return <- self.source.withdrawAvailable(maxAmount: maxAmount)
}
/// Closes the underlying FlowALP position by preparing repayment funds and closing with them.
///
/// This method:
/// 1. Calculates debt amount from position
/// 2. Creates external yield token source from AutoBalancer
/// 3. Swaps yield tokens → MOET via stored swapper
/// 4. Closes position with prepared MOET vault
///
/// This approach eliminates circular dependencies by preparing all funds externally
/// before calling the position's close method.
///
access(FungibleToken.Withdraw) fun closePosition(collateralType: Type): @{FungibleToken.Vault} {
pre {
self.isSupportedCollateralType(collateralType):
"Unsupported collateral type \(collateralType.identifier)"
}
post {
result.getType() == collateralType: "Withdraw Vault (\(result.getType().identifier)) is not of a requested collateral type (\(collateralType.identifier))"
}

// Step 1: Get debt amounts - returns {Type: UFix64} dictionary
let debtsByType = self.position.getTotalDebt()

// Enforce: one debt type per position
assert(
debtsByType.length <= 1,
message: "FUSDEVStrategy position must have at most one debt type, found \(debtsByType.length)"
)

// Step 2: Calculate total debt amount
var totalDebtAmount: UFix64 = 0.0
for debtAmount in debtsByType.values {
totalDebtAmount = totalDebtAmount + debtAmount
}

// Step 3: If no debt, close with empty sources array
if totalDebtAmount == 0.0 {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

With the repaymentBuffer, this will never be true, so maybe this instead?

Suggested change
if totalDebtAmount == 0.0 {
if totalDebtAmount == repaymentBuffer {

or create a new variable, and then we can check if the original debt amount without buffer is 0

let totalDebtAmountWithBuffer = totalDebtAmount + repaymentBuffer

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

the close position logic should always overpay debt slightly

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No. Look at this logic, totalDebtAmount should always > 0, and never be 0.0. So if totalDebtAmount == 0.0 will never become true.

            let repaymentBuffer: UFix64 = 0.00000001  // 1e-8
            totalDebtAmount = totalDebtAmount + repaymentBuffer

            // Step 3: If no debt, close with empty sources array
            if totalDebtAmount == 0.0 {

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

it seems to be an artifact from one of the previous versions, there is no repayment buffer any more

let resultVaults <- self.position.closePosition(
repaymentSources: []
)
// With one collateral type and no debt the pool returns at most one vault.
// Zero vaults is possible when the collateral balance is dust that rounds down
// to zero (e.g. drawDownSink had no capacity, or token reserves were empty).
assert(
resultVaults.length <= 1,
message: "Expected 0 or 1 collateral vault from closePosition, got \(resultVaults.length)"
)
// Zero vaults: dust collateral rounded down to zero — return an empty vault
if resultVaults.length == 0 {
destroy resultVaults
self.positionClosed = true
return <- DeFiActionsUtils.getEmptyVault(collateralType)
}
let collateralVault <- resultVaults.removeFirst()
destroy resultVaults
self.positionClosed = true
return <- collateralVault
}

// Step 4: Create external yield token source from AutoBalancer
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 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(
swapper: yieldToMoetSwapper,
source: yieldTokenSource,
uniqueID: self.copyID()
)

// Step 7: Close position - pool pulls exactly the debt amount 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()
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Better double check the collateral type?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

put a post check

assert(
collateralVault.getType() == collateralType,
message: "First vault returned from closePosition must be collateral (\(collateralType.identifier)), got \(collateralVault.getType().identifier)"
)

// Handle any overpayment dust (MOET) returned as the second vault
while resultVaults.length > 0 {
let dustVault <- resultVaults.removeFirst()
if dustVault.balance > 0.0 {
if dustVault.getType() == collateralType {
collateralVault.deposit(from: <-dustVault)
} else {
// @TODO implement swapping moet to collateral
destroy dustVault
}
} else {
destroy dustVault
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note we will destroy a non-empty collateral vault regardless the amount. This could is techinically possible since the returned vaults can have pending deposits, and overpay vaults.

I think we should do:

if dustVault.balance == 0 { destroy dustVault }
if dustVault.getType == collateralType { collateralVault.deposit(from: <- dustVault) }
// dustVault.getType() != collateralType
// swap and then deposit
...

}
}

destroy resultVaults
self.positionClosed = true
return <- collateralVault
}
/// Executed when a Strategy is burned, cleaning up the Strategy's stored AutoBalancer
access(contract) fun burnCallback() {
FlowYieldVaultsAutoBalancers._cleanupAutoBalancer(id: self.id()!)
Expand Down Expand Up @@ -301,9 +432,46 @@ access(all) contract FlowYieldVaultsStrategiesV2 {
uniqueID: uniqueID
)

// 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
)

// 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
)

// 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
)

// Set AutoBalancer sink for overflow -> recollateralize
balancerIO.autoBalancer.setSink(positionSwapSink, updateSinkID: true)

// Set AutoBalancer source for deficit recovery -> pull from Position
balancerIO.autoBalancer.setSource(positionSwapSource, updateSourceID: true)

// Store yield→MOET swapper in contract config for later access during closePosition
let yieldToMoetSwapperKey = FlowYieldVaultsStrategiesV2.getYieldToMoetSwapperConfigKey(uniqueID)!
FlowYieldVaultsStrategiesV2.config[yieldToMoetSwapperKey] = yieldToMoetSwapper
Copy link
Contributor

@liobrasil liobrasil Mar 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This writes per-vault runtime state into a contract-global dictionary, and nothing removes it on close/burn, so every opened+closed vault leaves a yieldToMoetSwapper_<id> entry behind.

Suggested change: keep the swapper on FUSDEVStrategy itself and stop persisting it in FlowYieldVaultsStrategiesV2.config.

Example shape:

access(self) let yieldToMoetSwapper: {DeFiActions.Swapper}

init(
    id: DeFiActions.UniqueIdentifier,
    collateralType: Type,
    position: @FlowALPv0.Position,
    yieldToMoetSwapper: {DeFiActions.Swapper}
) {
    self.uniqueID = id
    self.sink = position.createSink(type: collateralType)
    self.source = position.createSourceWithOptions(type: collateralType, pullFromTopUpSource: true)
    self.positionClosed = false
    self.position <- position
    self.yieldToMoetSwapper = yieldToMoetSwapper
}

Then in closePosition() use self.yieldToMoetSwapper directly, and in createStrategy(...) pass it into FUSDEVStrategy(...) instead of writing a yieldToMoetSwapper_<id> entry here.


// @TODO implement moet to collateral swapper
// let moetToCollateralSwapperKey = FlowYieldVaultsStrategiesV2.getMoetToCollateralSwapperConfigKey(uniqueID)
//
// FlowYieldVaultsStrategiesV2.config[moetToCollateralSwapperKey] = moetToCollateralSwapper
//
switch type {
case Type<@FUSDEVStrategy>():
return <-create FUSDEVStrategy(
Expand Down Expand Up @@ -514,6 +682,16 @@ access(all) contract FlowYieldVaultsStrategiesV2 {
}
}

/// @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,
Expand Down Expand Up @@ -595,6 +773,40 @@ access(all) contract FlowYieldVaultsStrategiesV2 {
uniqueID: uniqueID
)
}

/// Creates a Collateral -> Yield token swapper using UniswapV3
/// This is the REVERSE of _createYieldToCollateralSwapper
/// Used by AutoBalancer to pull collateral from Position and swap to yield tokens
///
access(self) fun _createCollateralToYieldSwapper(
collateralConfig: FlowYieldVaultsStrategiesV2.CollateralConfig,
yieldTokenEVMAddress: EVM.EVMAddress,
yieldTokenType: Type,
collateralType: Type,
uniqueID: DeFiActions.UniqueIdentifier
): UniswapV3SwapConnectors.Swapper {
// Reverse the swap path: collateral -> yield (opposite of yield -> collateral)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does this reverse computation happen a lot? Does it make sense to store the reversed path within the CollateralConfig?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

no, it's calculated only once during position creation, storing it separately would be redundant and it will increase risk of misconfiguration

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we'll need to store another path though: moet to collateral

let forwardPath = collateralConfig.yieldToCollateralUniV3AddressPath
let reversedTokenPath = forwardPath.reverse()

// Reverse the fee path as well
let forwardFees = collateralConfig.yieldToCollateralUniV3FeePath
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

let reversedFeePath = forwardFees.reverse()

// Verify the reversed path starts with collateral (ends with yield)
assert(
reversedTokenPath[reversedTokenPath.length - 1].equals(yieldTokenEVMAddress),
message: "Reversed path must end with yield token \(yieldTokenEVMAddress.toString())"
)

return self._createUniV3Swapper(
tokenPath: reversedTokenPath,
feePath: reversedFeePath,
inVault: collateralType, // ← Input is collateral
outVault: yieldTokenType, // ← Output is yield token
uniqueID: uniqueID
)
}
}

access(all) entitlement Configure
Expand Down Expand Up @@ -810,6 +1022,20 @@ 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())"
}

init(
univ3FactoryEVMAddress: String,
univ3RouterEVMAddress: String,
Expand Down
Loading