Skip to content

Nialexsan/fusdev strategy#215

Merged
nialexsan merged 35 commits intov0from
nialexsan/fusdev-strategy
Mar 31, 2026
Merged

Nialexsan/fusdev strategy#215
nialexsan merged 35 commits intov0from
nialexsan/fusdev-strategy

Conversation

@nialexsan
Copy link
Copy Markdown
Contributor

@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
Contributor 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
Contributor 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!

Comment thread cadence/contracts/FlowYieldVaultsStrategiesV2.cdc Outdated
// any surplus shares are still held by the AutoBalancer and are recovered here.
let excessShares <- yieldTokenSource.withdrawAvailable(maxAmount: UFix64.max)
if excessShares.balance > 0.0 {
let moetQuote = yieldToMoetSwapper.quoteOut(forProvided: excessShares.balance, reverse: false)
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.

Would it be better to build a sequential swapper from yield -> moet -> collateral? That way we only need to do 1 quote (from yield to collateral), and decide whether to swap: if collQuote.outAmount == 0, we could just burn all the yield token without converting them to moet and then burn.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

yes, sequential swapper is definitely way to go, I left it bare for now as we need to rip MOET out of the strategy, and I feel that with this it will be more obvious where it needs to be removed

zhangchiqing
zhangchiqing previously approved these changes Mar 30, 2026
Co-authored-by: Leo Zhang <zhangchiqing@gmail.com>
@nialexsan nialexsan requested a review from a team March 31, 2026 02:21
@nialexsan nialexsan merged commit ce6c29b into v0 Mar 31, 2026
6 of 7 checks passed
@nialexsan nialexsan deleted the nialexsan/fusdev-strategy branch March 31, 2026 02:26
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.

5 participants