Platform: Code4rena
Start Date: 07/07/2023
Pot Size: $121,650 USDC
Total HM: 36
Participants: 111
Period: 7 days
Judge: Picodes
Total Solo HM: 13
Id: 258
League: ETH
Rank: 17/111
Findings: 2
Award: $1,659.90
๐ Selected for report: 1
๐ Solo Findings: 1
๐ Selected for report: GalloDaSballo
1643.9812 USDC - $1,643.98
For Yield Vaults that use Lossy Strategies, the PT Vault will burn more Yield Vault Shares than intended when processing a withdrawal, socializing a loss at the advantage of early withdrawers
withdraw
calls _convertToShares
uint256 _shares = _convertToShares(_assets, Math.Rounding.Up);
Which uses the _currentExchangeRate()
This in turn computes the withdrawableAssets
by fetching yieldVault.maxWithdraw
and then mapping the principal vs the totalSupply
uint256 _withdrawableAssets = _yieldVault.maxWithdraw(address(this)); if (_withdrawableAssets > _totalSupplyToAssets) { _withdrawableAssets = _withdrawableAssets - (_withdrawableAssets - _totalSupplyToAssets); } if (_totalSupplyAmount != 0 && _withdrawableAssets != 0) { return _withdrawableAssets.mulDiv(_assetUnit, _totalSupplyAmount, Math.Rounding.Down); }
This calculation is based on maxWithdraw
, which is a view
function
Most implementation ofย maxWithdraw
will simply map the available tokens to the shares owned by the owner, see OZ for example:
function maxWithdraw(address owner) public view virtual returns (uint256) { return _convertToAssets(balanceOf(owner), Math.Rounding.Floor); }
Or similarly, Yearn V3
Due to the implementation, losses can be applied during YieldVault.withdraw
, causing the burning of more shares than intended
Because the PT Vault computes the shares to burn before accounting for a potential loss:
No loss will be accounted for in maxWithdraw
(since it will use static value for assets in the vault and assets in the strategy)
The loss will be locked during _withdraw
, but it will not be checked, the specifics of the loss are that it will cause burning of more shares in order to receive the intended assets
This will cause the user to pay shares
from PT Vault
But it will cause PT Vault to pay more yieldVault Shares
than intended, effectively diluting everyone else and socializing the losses on the laggards
_convertToShares
Ensures that the PPFS of the Yield Vault doesn't decrease, or add functions to handle lossy withdrawals
ERC4626
#0 - c4-sponsor
2023-07-19T23:40:28Z
asselstine marked the issue as sponsor confirmed
#1 - c4-judge
2023-08-08T10:45:42Z
Picodes marked the issue as satisfactory
#2 - PierrickGT
2023-08-10T22:45:49Z
I don't entirely understand the issue here, to me it looks like this is the intended behavior. Users knowingly deposited in a Vault relying on a YieldVault that employs Lossy Strategies to generate yield, let's say for example an option protocol, the position of the Vault loses value so all shares are backed by less underlying assets. When withdrawing, people will only be able to withdraw their shares of deposits which indeed socializes the loss. I think for this kind of YieldVaults, we will need to develop custom Vaults cause it's a pretty specific use case.
#3 - asselstine
2023-08-17T21:13:25Z
#4 - asselstine
2023-08-17T21:49:07Z
#5 - PierrickGT
2023-08-22T00:44:04Z
Working PR here: https://github.com/GenerationSoftware/pt-v5-vault/pull/37
๐ Selected for report: bin2chen
Also found by: 0x11singh99, 0xWaitress, 0xbepresent, ABAIKUNANBAEV, ArmedGoose, Bauchibred, DadeKuma, GREY-HAWK-REACH, GalloDaSballo, Inspecktor, Jeiwan, Kaysoft, MohammedRizwan, Rolezn, Vagner, alexzoid, alymurtazamemon, ayden, banpaleo5, catellatech, dacian, erebus, eyexploit, fatherOfBlocks, grearlake, joaovwfreire, keccak123, kutugu, lanrebayode77, markus_ether, nadin, naman1778, rvierdiiev, squeaky_cactus, volodya, yixxas
15.9228 USDC - $15.92
If a loss is incurred in a Yearn V3 Yield Vault, withdrawing will stop working until the Loss has been recovered
This will require either:
This is because the PT Vault uses the standard ERC4626 call to _yieldVault.withdraw(_assets, address(this), address(this));
function _withdraw( address _caller, address _receiver, address _owner, uint256 _assets, uint256 _shares ) internal virtual override { if (_caller != _owner) { _spendAllowance(_owner, _caller, _shares); } // If _asset is ERC777, `transfer` can trigger a reentrancy AFTER the transfer happens through the // `tokensReceived` hook. On the other hand, the `tokensToSend` hook, that is triggered before the transfer, // calls the vault, which is assumed not malicious. // // Conclusion: we need to do the transfer after the burn so that any reentrancy would happen after the // shares are burned and after the assets are transferred, which is a valid state. _burn(_owner, _shares); _yieldVault.withdraw(_assets, address(this), address(this)); /// @audit Default Withdraw Call SafeERC20.safeTransfer(IERC20(asset()), _receiver, _assets); emit Withdraw(_caller, _receiver, _owner, _assets, _shares); }
Meaning that Yearn V3 (and V2 as well) will interpret this as a No Loss Withdrawal
@nonreentrant("lock") def withdraw( assets: uint256, receiver: address, owner: address, max_loss: uint256 = 0, strategies: DynArray[address, MAX_QUEUE] = [] ) -> uint256:
Yearn V3 sets the default loss to 0 BPS, meaning that any loss in the withdrawal will cause a revert
Consider whether to use Yearn V3, and whether you should add a Lossy Withdrawal Option, or perhaps a Router that allows loss but protects people from losing too much
ERC4626
#0 - asselstine
2023-07-20T00:11:19Z
It seems that Yearn V3 does not adhere to the 4626 spec. I believe this is an issue with Yearn V3, and not our code.
ERC-4626:
[maxWithdraw] MUST NOT be higher than the actual maximum that would be accepted (it should underestimate if necessary).
The Yearn V3 maxWithdraw and it's internal implementation
The Yearn V3's 4626 withdraw() function default max_loss to zero, but reverts if there is any loss. This is inconsistent with the ERC-4626 spec.
If Yearn V3 stuck to the spec, instead of a revert we'd see:
// because maxWithdraw is zero, then exchangeRate is zero, therefore _assets = 0 uint256 _assets = _convertToAssets(_shares, Math.Rounding.Down); _withdraw(msg.sender, _receiver, _owner, _assets, _shares); // no-op
The withdraw would succeed but would burn shares and return zero assets. However, this is the point of allowing undercollateralized exits: to allow users to do whatever they want to do. It's a double-edge sword in that sense. Technically speaking, this is behaving as intended and should be fine for other 4626 vaults.
It seems to me we might need a custom Vault or adapter for Yearn V3.
#1 - c4-sponsor
2023-07-20T00:11:23Z
asselstine marked the issue as sponsor disputed
#2 - Picodes
2023-08-08T08:56:20Z
@asselstine was it in your stated intentions to do something compatible with Yearn V3?
I do agree with the sponsor here. Ultimately considering the diversity of ERC4626 implementations it's fair to consider that the vault will need to be adapted for custom implementations like Yearn V3. I'll downgrade to Low.
#3 - c4-judge
2023-08-08T08:56:41Z
Picodes changed the severity to QA (Quality Assurance)
#4 - c4-judge
2023-08-08T14:34:17Z
Picodes marked the issue as grade-b
#5 - asselstine
2023-08-08T17:49:31Z
@Picodes Yes! We will write a custom adapter for Yearn V3.
The Vault works as intended for yield sources that strictly stick to 4626, so I don't see an issue. That being said if you want to retain the issue under 'QA' for posterity I understand.