Platform: Code4rena
Start Date: 04/03/2024
Pot Size: $36,500 USDC
Total HM: 9
Participants: 80
Period: 7 days
Judge: hansfriese
Total Solo HM: 2
Id: 332
League: ETH
Rank: 20/80
Findings: 2
Award: $171.96
🌟 Selected for report: 1
🚀 Solo Findings: 0
🌟 Selected for report: DarkTower
Also found by: 0xJaeger, 0xJoyBoy03, 0xRiO, 0xkeesmark, 0xlemon, 0xmystery, Abdessamed, AcT3R, Afriauditor, AgileJune, Al-Qa-qa, Aymen0909, Daniel526, DanielTan_MetaTrust, Dots, FastChecker, Fitro, GoSlang, Greed, Krace, McToady, SoosheeTheWise, Tripathi, asui, aua_oo7, btk, crypticdefense, d3e4, dd0x7e8, dvrkzy, gesha17, iberry, kR1s, leegh, marqymarq10, n1punp, pa6kuda, radin100, sammy, smbv-1923, trachev, turvy_fuzz, valentin_s2304, wangxx2026, y4y, yotov721, yvuchev, zhaojie
1.4652 USDC - $1.47
The claimYieldFeeShares
function in the contract is responsible for allowing the yield fee recipient to claim their shares of the yield fee. However, a critical issue has been identified in the deduction logic within the function:
function claimYieldFeeShares(uint256 _shares) external onlyYieldFeeRecipient { if (_shares == 0) revert MintZeroShares(); uint256 _yieldFeeBalance = yieldFeeBalance; if (_shares > _yieldFeeBalance) revert SharesExceedsYieldFeeBalance(_shares, _yieldFeeBalance); //@audit will decrease all fee balance regardless of amount of shares claimed yieldFeeBalance -= _yieldFeeBalance; _mint(msg.sender, _shares); emit ClaimYieldFeeShares(msg.sender, _shares); }
As it can be seen the function decrease the yield fee balance (yieldFeeBalance
) not based on the _shares
parameter provided to the function, but instead the entire balance (_yieldFeeBalance
) is deducted, which can result in a loss of funds for the fee recipient because the remaining yield shares (yieldFeeBalance - _shares
) will be removed and the recipient will never be able to mint them again.
The incorrect deduction logic in the claimYieldFeeShares
function can lead to a significant loss of funds for the yield fee recipient.
Manual review, VS Code
Review and correct the deduction logic within the claimYieldFeeShares
function to ensure that only the appropriate amount of yield fee is deducted based on the _shares
parameter provided.
function claimYieldFeeShares(uint256 _shares) external onlyYieldFeeRecipient { if (_shares == 0) revert MintZeroShares(); uint256 _yieldFeeBalance = yieldFeeBalance; if (_shares > _yieldFeeBalance) revert SharesExceedsYieldFeeBalance(_shares, _yieldFeeBalance); -- yieldFeeBalance -= _yieldFeeBalance; ++ yieldFeeBalance -= _shares; _mint(msg.sender, _shares); emit ClaimYieldFeeShares(msg.sender, _shares); }
Error
#0 - c4-pre-sort
2024-03-11T22:14:44Z
raymondfam marked the issue as sufficient quality report
#1 - c4-pre-sort
2024-03-11T22:14:50Z
raymondfam marked the issue as duplicate of #10
#2 - c4-pre-sort
2024-03-13T04:38:40Z
raymondfam marked the issue as duplicate of #59
#3 - c4-judge
2024-03-15T07:38:19Z
hansfriese marked the issue as satisfactory
🌟 Selected for report: Aymen0909
Also found by: 0xmystery, Abdessamed, FastChecker, Giorgio, Tripathi, btk, cheatc0d3, trachev, turvy_fuzz
170.4878 USDC - $170.49
https://github.com/code-423n4/2024-03-pooltogether/blob/main/pt-v5-vault/src/PrizeVault.sol#L454-L472 https://github.com/code-423n4/2024-03-pooltogether/blob/main/pt-v5-vault/src/PrizeVault.sol#L355-L366 https://github.com/code-423n4/2024-03-pooltogether/blob/main/pt-v5-vault/src/PrizeVault.sol#L489-L508
When a user who has deposited assets into the PrizeVault wishes to withdraw (or redeem) them, they can do so by calling either the withdraw
or redeem
functions.
Under normal conditions in the vault, users expect to receive their full deposited asset amount back, as there is a 1:1 exchange ratio between the asset amount and shares.
However, if the underlying yield vault experiences a loss, this exchange rate will decrease. This is highlighted in the previewWithdraw
function (or previewRedeem
/convertToAssets
function):
function previewWithdraw(uint256 _assets) public view returns (uint256) { uint256 _totalAssets = totalAssets(); // No withdrawals can occur if the vault controls no assets. if (_totalAssets == 0) revert ZeroTotalAssets(); uint256 totalDebt_ = totalDebt(); if (_totalAssets >= totalDebt_) { return _assets; } else { // Follows the inverse conversion of `convertToAssets` return _assets.mulDiv(totalDebt_, _totalAssets, Math.Rounding.Up); } } function convertToAssets(uint256 _shares) public view returns (uint256) { uint256 totalDebt_ = totalDebt(); uint256 _totalAssets = totalAssets(); if (_totalAssets >= totalDebt_) { return _shares; } else { // If the vault controls fewer assets than what has been deposited, a share will be worth a // proportional amount of the total assets. This can happen due to fees, slippage, or loss // of funds in the underlying yield vault. return _shares.mulDiv(_totalAssets, totalDebt_, Math.Rounding.Down); } }
function totalAssets() public view returns (uint256) { return yieldVault.convertToAssets(yieldVault.balanceOf(address(this))) + _asset.balanceOf(address(this)); }
As shown, if the underlying yield vault experiences a loss, the total assets of the vault given by totalAssets()
will decrease and might go below the total shares value totalDebt()
.
This will trigger the second if
statement block, in which the calculated asset (or shares) amount will be converted using the ratio totalAssets/totalDebt
(or its inverse for shares).
When a user redeems (or withdraws), if the yield vault experiences a loss while their transaction is pending (waiting in the mempool) and the total assets drop below the total vault debt, they will receive fewer assets after calling either the withdraw
/redeem
functions than they expected. Instead of a 1:1 ratio, they will use the current totalAssets/totalDebt
ratio.
To illustrate this issue, consider the following scenario:
In the vault, we have totalAssets() = 51000
and totalDebt() = 50000
.
Bob previously deposited 1000 tokens and received 1000 shares in return.
Bob wants to redeem 500 shares and expects to get his 500 tokens back since currently totalAssets() > totalDebt()
(so there's a 1:1 ratio).
While Bob's transaction is pending in the mempool, the yield vault experiences a loss, and totalAssets() = 46000
drops below totalDebt() = 50000
.
When Bob's transaction goes through, he will receive: (500 * 46000) / 50000 = 460 < 500
.
Thus, instead of receiving 500 tokens, he only gets 460 back, resulting in a loss of 40 tokens.
If Bob had known that the yield vault had experienced a loss, he would have waited until the exchange rate increased again to withdraw his full amount.
This issue is also present in the withdraw
function, but in that case, the user will be burning more shares to get the same amount and still incurring a loss.
Both withdraw
/redeem
functions lack slippage protection, which can lead to users losing funds in the event of a yield vault loss.
Manual review, VS Code
Both withdraw
/redeem
functions should include slippage protection parameters provided by the users (either minimum amount out for redeem
function or maximum shares in for withdraw
function).
Context
#0 - c4-pre-sort
2024-03-11T23:48:02Z
raymondfam marked the issue as sufficient quality report
#1 - c4-pre-sort
2024-03-11T23:48:09Z
raymondfam marked the issue as duplicate of #90
#2 - raymondfam
2024-03-13T05:08:55Z
Slippage protection is indeed beneficial to circumvent unfavorably unexpected timing.
#3 - c4-pre-sort
2024-03-13T05:09:00Z
raymondfam marked the issue as not a duplicate
#4 - c4-pre-sort
2024-03-13T05:09:04Z
raymondfam marked the issue as primary issue
#5 - c4-sponsor
2024-03-13T22:31:35Z
trmid (sponsor) confirmed
#6 - trmid
2024-03-13T22:35:17Z
Providing depositors with slippage protection is a nice improvement! The suggested mitigation would break the ERC4626 spec requirements on the PrizeVault, so an alternate strategy will likely be used that either adds additional deposit
and withdraw
functions that have slippage params, or provides an external router that can provide this protection for the depositor.
#7 - c4-judge
2024-03-15T07:59:05Z
hansfriese marked the issue as satisfactory
#8 - c4-judge
2024-03-15T07:59:08Z
hansfriese marked the issue as selected for report
#9 - d3e4
2024-03-19T21:53:15Z
Is this not a design suggestion rather than a bug, and should belong in Analysis? To remain EIP-4626 compliant a slippage protection would have to be implemented in entirely new functionality; not by amending already existing code. Therefore, it cannot be said that there is something wrong with the current code.
#10 - 0xbtk
2024-03-20T20:56:43Z
Is this not a design suggestion rather than a bug, and should belong in Analysis? To remain EIP-4626 compliant a slippage protection would have to be implemented in entirely new functionality; not by amending already existing code. Therefore, it cannot be said that there is something wrong with the current code.
EIP-4626 state that:
If implementors intend to support EOA account access directly, they should consider adding an additional function call for deposit/mint/withdraw/redeem with the means to accommodate slippage loss or unexpected deposit/withdrawal limits, since they have no other means to revert the transaction if the exact output amount is not achieved.
#11 - hansfriese
2024-03-21T06:07:25Z
I think it's eligible to be a Medium for the specific withdraw/redeem logic of PrizeVault
. Will maintain it as a Medium.
#12 - trmid
2024-03-21T22:44:18Z