Skip to content

Nialexsan/fusdev strategy#215

Open
nialexsan wants to merge 33 commits intov0from
nialexsan/fusdev-strategy
Open

Nialexsan/fusdev strategy#215
nialexsan wants to merge 33 commits intov0from
nialexsan/fusdev-strategy

Conversation

@nialexsan
Copy link
Copy Markdown
Collaborator

@nialexsan nialexsan commented Mar 16, 2026

Closes: #???

Description

this PR contains bulk changes related only to FUSDEV strategy from #183
so it's easier to review just the strategy itself withoutt the second strategy


For contributor use:

  • Targeted PR against master branch
  • Linked to Github issue with discussion and accepted design OR link to spec that describes this work.
  • Code follows the standards mentioned here.
  • Updated relevant documentation
  • Re-reviewed Files changed in the Github PR explorer
  • Added appropriate labels

@nialexsan nialexsan force-pushed the nialexsan/fusdev-strategy branch from 6de117f to c6a0634 Compare March 16, 2026 16:13
@onflow onflow deleted a comment from claude bot Mar 16, 2026
@onflow onflow deleted a comment from claude bot Mar 16, 2026
@onflow onflow deleted a comment from claude bot Mar 16, 2026
@nialexsan nialexsan force-pushed the nialexsan/fusdev-strategy branch from e55de25 to 48ef07b Compare March 16, 2026 19:46
@onflow onflow deleted a comment from claude bot Mar 16, 2026
@onflow onflow deleted a comment from claude bot Mar 16, 2026
@onflow onflow deleted a comment from claude bot Mar 18, 2026
@onflow onflow deleted a comment from claude bot Mar 18, 2026
@onflow onflow deleted a comment from claude bot Mar 18, 2026
@nialexsan
Copy link
Copy Markdown
Collaborator Author

nialexsan commented Mar 20, 2026

Swapper reconstruction vs. serialization — Instead of storing a {DeFiActions.Swapper} in config (which required serialization of a complex struct), swappers are now rebuilt from the stored CollateralConfig. This is cleaner and avoids potential issues with stale or unresolvable stored swappers.

this change fixes an issue with exceeding compute limit while reading through the dict and retrieving a big object (Swapper) from it. Also this approach makes the implementation more flexible, in case the swap paths are updated after a strategy is created.

Comment on lines +103 to +120
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
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

This is the same behaviour as a SwapSource, but ignores the maxAmount provided by the DeFiActions.Source interface.

Are we sure that this is necessary, and not masking an underlying bug/design flaw? I thought that DeFiActions used UFix64.max as a senteniel value for cases like this, or am I misunderstanding here?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

It kinda compensates the rounding/conversion error on ERC4626 vault and design/implementation of _repayDebtsFromSources method in FlowALPv0 that withdraws exact debt amount.

If we change that _repayDebtsFromSources to withdraw UFix64.max, then the BufferedSwapSource could be replaces with a regular SwapSource, though in this case the FlowALP will always overwithdraw even if the repayment source provides exact debt amount.

Copy link
Copy Markdown
Member

@zhangchiqing zhangchiqing Mar 26, 2026

Choose a reason for hiding this comment

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

I think what @jribbink point out is the fact that the input argument of this function (maxAmount) is ignored, which means no matter how much the caller wants to withdraw, it will first swap everything from source to the token to be returned. That would also mean, the balance of the return vault could potentially be more than what was asked for (the input maxAmount). Like if I just ask for 10 FLOW, it might sell all my yield tokens and return 12 FLOW.

This is ok in the case of closing the strategy here, because we want to sell all yield tokens. For instance, I have 100 FLOW as collateral, I create a strategy to borrow 50 MOET and swap into yield tokens, now I'm closing this strategy, the yield tokens is worth 60 MOET after earning some yields, however my debt is only 52 MOET (50 + some interest), and then closing the underlaying position of the strategy hits the logic in FlowALP, which only asks to repay the owing MOET, that is just 50 MOET. So if I only swap yield tokens that just covers the 52 MOET debt instead of swapping all, that's not good, because the remaining un-swapped yield tokens (worth 8 MOET) left in the autobalancer will get burned along with the autobalancer (see burnCallback, which end up causing loss for the user). So that's why we chose to swap all yield tokens, and ignore the input maxAmount which is the max debt. After all yield tokens are swapped, and additional MOET (8 MOET) after the repayment will be swapped back to collateral, in other words, user will receives additonal FLOW after closing the strategy, which is a good UX.

Now I have one more question:
As @jribbink suggested, if we are gonna ignore the maxAmount (the total debt amount), and always swap all, why not just self.source.withdrawAvailable(maxAmount: UFix64.max) instead of self.source.withdrawAvailable(maxAmount: self.source.minimumAvailable()) in the BufferedSwapSource.withdrawAvailable? That way, we could skip the query of self.source.minimumAvailable().

If we change that _repayDebtsFromSources to withdraw UFix64.max, then the BufferedSwapSource could be replaces with a regular SwapSource, though in this case the FlowALP will always overwithdraw even if the repayment source provides exact debt amount.

@nialexsan I don't quite understand your answer. To me, it make sense to use the regular swap source if it always swap all, and I think regardless overwithdraw, or underwithdraw or withdraw exact, we always want to swap all, right? Because in the case of underwithdraw (debt is more than the yield token value) we have the step 6 pre-supplyment step to sell collateral and covers the shortfall, so it's ok to always swap all.

My point is, if the regular DefiActions.SwapSource.withdrawAvailable can always swap all regardless any amount being called with, then we don't need the BufferedSwapSource.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

@jribbink @zhangchiqing
thanks for the discussion
there's an issue with the multiswapper in FlowActions that causes this behaviour
this fix should address it and it should be possible to use a regular swap source
onflow/FlowActions#158

} else {
// @TODO implement swapping moet to collateral
destroy dustVault
Burner.burn(<-v)
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Should we give us some visibility of the reason we burn the tokens? Because the decision depends on a 3rd party quote.

We have 2 (or more?) code paths that lead to token burning event: 1) vault has 0 balance 2) the token worth nothing.

Maybe it's useful to add a TokenBurned event with the amount and the quote as evidence of why we decided to burn it, in case we need to debug, since the swapper price might be either outdated or misbehaving?

): UniswapV3SwapConnectors.Swapper {
let yieldToCollPath = collateralConfig.yieldToCollateralUniV3AddressPath
let yieldToCollFees = collateralConfig.yieldToCollateralUniV3FeePath
assert(yieldToCollPath.length >= 2, message: "yieldToCollateral path must have at least 2 elements")
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Suggested change
assert(yieldToCollPath.length >= 2, message: "yieldToCollateral path must have at least 2 elements")
assert(yieldToCollPath.length >= 2, message: "yieldToCollateral path requires at least yield and collateral tokens, got \(yieldToCollPath.length)")

uniqueID: DeFiActions.UniqueIdentifier
): UniswapV3SwapConnectors.Swapper {
let yieldToCollPath = collateralConfig.yieldToCollateralUniV3AddressPath
let yieldToCollFees = collateralConfig.yieldToCollateralUniV3FeePath
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Better validate this as well

assert(
      yieldToCollFees.length == yieldToCollPath.length - 1,
      message: "Fee array length (\(yieldToCollFees.length)) must equal path hops (\(yieldToCollPath.length - 1))"
  )

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

this assertion exists in collateralConfig init method

}
} else {
destroy dustVault
Burner.burn(<-v)
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

could we mention the burning token behavior (the cleanup) in the comments of closePosition as well?

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.
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

What is the rounding underestimate caused by? Is it bounded? If the source contains much more funds than is needed, we'll swap much more than we need to.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

current implementation of ERC4626 connector rounds down on quote it and redeem
it's bounded to 1 unit on the evm side, which is 0.000001 PYUSD0.
the source won't contain too much funds, just enough to cover the debt. Pre-supplement step buffers the debt by 2% of the shortfall to cover the swap fees

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

What is the rounding underestimate caused by? I

Image

collateralType: collateralType,
uniqueID: self.uniqueID!
)
let shortfall = totalDebtAmount - expectedMOET
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

It seems like both these changes are related to the Swapper quotes being unreliable:

  • BufferedSwapSource swaps all funds available in the source, ignoring the quote, because it might contain rounding errors
  • This "pre-supplement extra moet" step adds 1% to account for slippage/rounding unaccounted for in the quote from collateralToMoetSwapper

But when we calculate the shortfall, we are using expectedMOET, which is the result of a swapper quote. Why do we trust this quote and not the others?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

expectedMOET uses quoteOut, which is more precise than quoteIn in other cases
quoteOut in this case goes with the direction of the swap, and it rounds down all calculations, just like swap

Copy link
Copy Markdown
Member

@jordanschalm jordanschalm Mar 23, 2026

Choose a reason for hiding this comment

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

I guess what I'm trying to drive at is: can we change the implementation of the Swapper/Source to handle these rounding errors more gracefully?

If my understanding is correct, currently the yieldTokenSource might estimate it can provide X tokens, but will actually provide X-epsilon tokens. From this comment, I understand that this epsilon is bounded.

Looking at this comment:

SwapSource computed 98 shares was enough to produce 100 PYUSD0 — it wasn't

Is there anything we can do in the SwapSource or its underlying Swapper, to correct its estimation of its own capacity, given the error is known and bounded? My understanding of the whole picture here still feels a bit fuzzy, but intuitively it seems possible to account for a known, bounded error. I feel like effort spent making the minimumAvailable and quote{In/Out} functions always accurate will be broadly beneficial and avoid further need for this kind of workaround. By accurate, I mean choosing the direction of error:

  • minimumAvailable should always err on the side of underestimating -> being able to draw more tokens from the source is OK, thinking you can draw more than are actually available is not
  • quoteIn should always err on the side of overestimating -> getting more tokens from the swap is OK, getting less is not
  • quoteOut should always err on the side of underestimating -> getting more tokens from the swap is OK, getting less is not

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

that would be easier to achieve if there was only one hop, but in our implementation there are at least two hops, and the rounding error accumulates regardless of the precision

separately from that, ERC4626 contract has six different preview/quote methods that have different rounding modes, and we're just using two of them for quote it and quote out without any additional flags, so fundamentally it will be super tricky to fit them in

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

where is the ERC4626 connector?

  yieldToMoetSwapper = MultiSwapper containing:
    └── SequentialSwapper:
          1. yieldToUnderlying  ← MorphoERC4626SwapConnectors.Swapper (isReversed: true)
          2. underlyingToDebt   ← UniV3 swapper

  So the flow is:
  BufferedSwapSource
    ├── source: yieldTokenSource (AutoBalancer external source - just pulls yield tokens)
    └── swapper: yieldToMoetSwapper
                   └── yieldToken → [ERC4626 redeem] → underlying → [UniV3] → MOET

  The ERC4626 is inside the swapper — specifically yieldToUnderlying which redeems ERC4626 shares (FUSDEV) back to the underlying asset (PYUSD0), then underlyingToDebt swaps that to MOET via Uniswap.

I think the "at least two hops" Alex mentioned is the "yieldToken → [ERC4626 redeem] → underlying → [UniV3] → MOET" swap route, which is previewed with " FUSDEV → [ERC4626 previewRedeem, floor] → PYUSD0 → [UniV3 quote] → MOET"

But I think the shortfall is actually accurate (Meaning shortfall is always slightly bigger than the actual shortfall, rather than smaller). In other words, I think we don't need the 1% buffer. Why? because the expectedMOET is accurate.

The pre-supplyment already paid the shortfall (self.position.deposit(from: <-extraMOET)), which is slightly more than the actual shortfall , so closing the position with yield token wrapped with SwapSource should be enough to receive the remaining MOET after swap.

Note the pre-supplyment is converting to and paying debts with MOET rather than the yield token, so there is no more ERC4626 rounding error to be hit. The ERC4626 rounding error has been covered by the calculation of expectedMOET.

I think we can validate by removing the 1% buffer and add assertion for extraMOET.balance > shortfall. If closePosition didn't revert, then we should be good. Or did I miss something?

let quote = collateralToMoetSwapper.quoteIn(forDesired: buffered, reverse: false)
if quote.inAmount > 0.0 {
let extraCollateral <- self.source.withdrawAvailable(maxAmount: quote.inAmount)
assert(extraCollateral.balance > 0.0,
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

If user doesn't have enough collateral to pay for the debt, we will panic and don't let them close the strategy?

Could you explain why we decided to panic, instead of continue closing the strategy?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

if user doesn't have enough collateral to cover the debt, the position is unhealthy and it should be under liquidation

// Add 1% buffer to account for swap slippage/rounding in the collateral→MOET leg
let buffered = shortfall + shortfall / 100.0
let quote = collateralToMoetSwapper.quoteIn(forDesired: buffered, reverse: false)
if quote.inAmount > 0.0 {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Is quote.inAmount == 0 an edge case?

(nit) To me, this should never happen (you can get out token by putting 0 in token), but even if this is possible, the quote.inAmount == 0 case only matters for the self.source.withdrawAvailable, so technically it only needs the if quote.inAmount > 0 check to wrap the withdraw, and continue the swap with the quote and deposit regardless the inAmount value. But that would be weird (you get desired MOET tokens by providing nothing), it's almost like a bug in the swapper. So if we don't think quote.inMount == 0 would ever make sense, we could just panic by replacing with an assert .

)
}
access(contract) view fun copyID(): DeFiActions.UniqueIdentifier? { return self.uniqueID }
access(contract) fun setID(_ id: DeFiActions.UniqueIdentifier?) { self.uniqueID = id }
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Once set with a non-nil uniqueID, does it make sense to be set with nil ID again? Do we want to prevent that or keeping it flexible?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

this is a standard implementation across the project
It's not usually reset the id to nil, in our code. I'd rather keep if flexible for now

@nialexsan
Copy link
Copy Markdown
Collaborator Author

@jribbink @jordanschalm
here's a claude generated explanation why pre-supplement and buffered swapper are required:

● Setup (UniV3 fee exaggerated to 1% for readability; FUSDEV redemption is free)

  ---
  Open position

  Borrow:                     100 MOET
  MOET→PYUSD0 (1% UniV3):     floor(100 × 0.99) = 99 PYUSD0
  Deposit into fresh FUSDEV:  receive 99 shares at 1:1

  Vault:  totalAssets = 99,  totalSupply = 99

  Yield accrues

  Vault:  totalAssets = 101,  totalSupply = 99  (2 PYUSD0 yield)

  ---
  Close — Step 1: check if yield covers debt

  quoteOut(99 shares):
    FUSDEV→PYUSD0 (free): floor(99 × 101 / 99) = 101 PYUSD0
    PYUSD0→MOET (1% fee): floor(101 × 0.99)    = 99 MOET

  99 < 100 debt  →  shortfall = 1 MOET

  The 2% round-trip fee exceeds the 2% yield by a rounding unit.

  ---
  Close — Step 2: pre-supplement

  buffered = 1 × 1.01 = 1.01 MOET
  Pull PYUSD0 collateral, swap → deposit 1 MOET to reduce debt

  Remaining debt:  99 MOET

  ---
  Close — Step 3a: repay with SwapSource — fails

  quoteIn(99 MOET):
    PYUSD0 needed: ceil(99 / 0.99)        = 100 PYUSD0
    shares needed: floor(100 × 99 / 101)  = floor(98.01) = 98 shares

  Redeem 98 shares (free ERC4626):
    floor(98 × 101 / 99) = floor(99.97) = 99 PYUSD0

  PYUSD0→MOET (1% fee):
    floor(99 × 0.99) = floor(98.01) = 98 MOET

  98 < 99 debt  ✗

  SwapSource computed 98 shares was enough to produce 100 PYUSD0 — it wasn't. ERC4626 floor
  division turned 100 into 99, and the UniV3 fee turned 99 into 98.

  ---
  Close — Step 3b: repay with BufferedSwapSource — succeeds

  Pull all 99 shares unconditionally:
    FUSDEV→PYUSD0: floor(99 × 101 / 99) = 101 PYUSD0
    PYUSD0→MOET:   floor(101 × 0.99)    = 99 MOET

  99 >= 99 debt  ✓

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")
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Can we also add assertion to check that after closing the yield vault, the debt is reduced.

We might want to assert that after opening and closing the yield vault, the total collateral amount should be very close to the original amount, there should be only a tiny amount loss due to running into the code path of expectedMOET < totalDebtAmount where some collateral has to be sold, right?

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.
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

What is the rounding underestimate caused by? I

Image

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,
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

should we just assert extraMOET.balance > shortfall?

collateralType: collateralType,
uniqueID: self.uniqueID!
)
let shortfall = totalDebtAmount - expectedMOET
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

where is the ERC4626 connector?

  yieldToMoetSwapper = MultiSwapper containing:
    └── SequentialSwapper:
          1. yieldToUnderlying  ← MorphoERC4626SwapConnectors.Swapper (isReversed: true)
          2. underlyingToDebt   ← UniV3 swapper

  So the flow is:
  BufferedSwapSource
    ├── source: yieldTokenSource (AutoBalancer external source - just pulls yield tokens)
    └── swapper: yieldToMoetSwapper
                   └── yieldToken → [ERC4626 redeem] → underlying → [UniV3] → MOET

  The ERC4626 is inside the swapper — specifically yieldToUnderlying which redeems ERC4626 shares (FUSDEV) back to the underlying asset (PYUSD0), then underlyingToDebt swaps that to MOET via Uniswap.

I think the "at least two hops" Alex mentioned is the "yieldToken → [ERC4626 redeem] → underlying → [UniV3] → MOET" swap route, which is previewed with " FUSDEV → [ERC4626 previewRedeem, floor] → PYUSD0 → [UniV3 quote] → MOET"

But I think the shortfall is actually accurate (Meaning shortfall is always slightly bigger than the actual shortfall, rather than smaller). In other words, I think we don't need the 1% buffer. Why? because the expectedMOET is accurate.

The pre-supplyment already paid the shortfall (self.position.deposit(from: <-extraMOET)), which is slightly more than the actual shortfall , so closing the position with yield token wrapped with SwapSource should be enough to receive the remaining MOET after swap.

Note the pre-supplyment is converting to and paying debts with MOET rather than the yield token, so there is no more ERC4626 rounding error to be hit. The ERC4626 rounding error has been covered by the calculation of expectedMOET.

I think we can validate by removing the 1% buffer and add assertion for extraMOET.balance > shortfall. If closePosition didn't revert, then we should be good. Or did I miss something?

@nialexsan nialexsan changed the base branch from main to v0 March 25, 2026 20:05
@nialexsan nialexsan requested a review from zhangchiqing March 26, 2026 18:57
@nialexsan
Copy link
Copy Markdown
Collaborator Author

@zhangchiqing you're right, the 1% buffer is not required any more after all other roundings.
the BuffredSwapSource will be renamed to something like UnboundedSwapSource in the next contract version

@codecov
Copy link
Copy Markdown

codecov bot commented Mar 26, 2026

Codecov Report

❌ Patch coverage is 26.47975% with 236 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
cadence/contracts/FlowYieldVaultsStrategiesV2.cdc 26.47% 236 Missing ⚠️

📢 Thoughts on this report? Let us know!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants