Conversation
6de117f to
c6a0634
Compare
e55de25 to
48ef07b
Compare
| /// 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 |
There was a problem hiding this comment.
this is removed to keep the contract updatable
|
Claude finished @nialexsan's task in 2m 38s —— View job Code Review
SummaryThis PR extracts the FUSDEV strategy from #183 for focused review. The changes are well-motivated and the code is generally clean. Below are my findings, ordered by severity.
|
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. |
| 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 | ||
| } |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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.
| } else { | ||
| // @TODO implement swapping moet to collateral | ||
| destroy dustVault | ||
| Burner.burn(<-v) |
There was a problem hiding this comment.
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") |
There was a problem hiding this comment.
| 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 |
There was a problem hiding this comment.
Better validate this as well
assert(
yieldToCollFees.length == yieldToCollPath.length - 1,
message: "Fee array length (\(yieldToCollFees.length)) must equal path hops (\(yieldToCollPath.length - 1))"
)
There was a problem hiding this comment.
this assertion exists in collateralConfig init method
| } | ||
| } else { | ||
| destroy dustVault | ||
| Burner.burn(<-v) |
There was a problem hiding this comment.
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. |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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
| collateralType: collateralType, | ||
| uniqueID: self.uniqueID! | ||
| ) | ||
| let shortfall = totalDebtAmount - expectedMOET |
There was a problem hiding this comment.
It seems like both these changes are related to the Swapper quotes being unreliable:
BufferedSwapSourceswaps 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?
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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:
minimumAvailableshould 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 notquoteInshould always err on the side of overestimating -> getting more tokens from the swap is OK, getting less is notquoteOutshould always err on the side of underestimating -> getting more tokens from the swap is OK, getting less is not
There was a problem hiding this comment.
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
| 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, |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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 { |
There was a problem hiding this comment.
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 } |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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
|
@jribbink @jordanschalm |
| 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") |
There was a problem hiding this comment.
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?
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:
masterbranchFiles changedin the Github PR explorer