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: 35/80
Findings: 2
Award: $33.22
🌟 Selected for report: 0
🚀 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
https://github.com/code-423n4/2024-03-pooltogether/blob/main/pt-v5-vault/src/PrizeVault.sol#L611
Impact:
The function claimYieldFeeShares
allows the yieldFeeRecipient
to claim shares corresponding to assets in yieldVault
. However regardless of the amount of _shares
they request (providing _shares <= yieldFeeBalance
), the yieldFeeBalance
gets reset to zero, meaning any access to the remaining assets in yieldVault
is permanently lost in cases where _shares < yieldFeeBalance
.
This is likely caused by the function mistakenly subtracting the entire yieldFeeBalance
from itself, rather than _shares
:
- yieldFeeBalance -= _yieldFeeBalance;` + yieldFeeBalance -= _shares;
Proof of Concept:
Add the following test to PrizeVault.t.sol
to confirm that yieldFeeRecipient
's tokens in the underlying yield vault become unredeemable:
function test_YieldFeeBalanceAlwaysReturnsToZero() public { vault.setYieldFeePercentage(1e8); // 10% vault.setYieldFeeRecipient(bob); assertEq(vault.totalDebt(), 0); // Make an initial deposit underlyingAsset.mint(alice, 1e18); vm.startPrank(alice); underlyingAsset.approve(address(vault), 1e18); vault.deposit(1e18, alice); vm.stopPrank(); // Add funds to liquidiate underlyingAsset.mint(address(vault), 1e18); vault.setLiquidationPair(address(this)); uint256 maxLiquidation = vault.liquidatableBalanceOf(address(underlyingAsset)); uint256 amountOut = maxLiquidation; vault.transferTokensOut(address(0), bob, address(underlyingAsset), amountOut); // Confirm there are now fees to be claimed vm.startPrank(bob); uint256 claimableFees = vault.yieldFeeBalance(); assert(claimableFees > 1); // Bob claims some amount less than yieldFeeBalance vault.claimYieldFeeShares(1); // Confirm yieldFeeBalance gets set back to zero despite not claiming all assertEq(vault.yieldFeeBalance(), 0); // Bob withdraws uint256 bobBalance = vault.balanceOf(bob); vault.redeem(bobBalance, bob, bob); vm.stopPrank(); checkInvariants(); // Confirm the left over claimableFees are in the contract assert(underlyingAsset.balanceOf(address(vault)) >= claimableFees - 1); // Alice withdraws uint256 aliceBalance = vault.balanceOf(alice); vm.startPrank(alice); vault.redeem(aliceBalance, alice, alice); vm.stopPrank(); // Confirm assets in the contract were used cover Alice's redeem assertEq(underlyingAsset.balanceOf(address(vault)), 0); // Confirm there are still assets in the vault (Alice's deposited 1e18 - the balance that was in the contract) assert(vault.totalAssets() >= claimableFees - 1); console.log("Total Assets in yield vault", vault.totalAssets()); // Confirm there is no longer any prizeVault token supply to redeem these assets assertEq(vault.totalSupply(), 0); // Alice deposits and redeems a single token to attempt to get back the stuck funds vm.startPrank(alice); console.log("Alice bal", underlyingAsset.balanceOf(alice)); underlyingAsset.approve(address(vault), 1); vault.deposit(1, alice); console.log("Contract balance", underlyingAsset.balanceOf(address(vault))); vault.redeem(1, alice, alice); // Confirm that the stuck assets remain in the yield vault console.log("Total Assets in yield vault end", vault.totalAssets()); }
Recommended Mitigation: It's likely that the developers intention was the following:
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); }
This would allow the yieldFeeRecipient
to claim partial amounts of their yieldFeeBalance
without losing access to the remainder.
Other
#0 - c4-pre-sort
2024-03-11T21:55:04Z
raymondfam marked the issue as sufficient quality report
#1 - c4-pre-sort
2024-03-11T21:55:11Z
raymondfam marked the issue as duplicate of #10
#2 - c4-pre-sort
2024-03-13T04:38:32Z
raymondfam marked the issue as duplicate of #59
#3 - c4-judge
2024-03-15T07:37:30Z
hansfriese changed the severity to 3 (High Risk)
#4 - c4-judge
2024-03-15T07:38:54Z
hansfriese marked the issue as satisfactory
31.7525 USDC - $31.75
When undertaking the review of the PoolTogether V5 Vault codebase the following steps were taken:
A total of approximately 30 hours was spent conducting the security review of this codebase over a 7 day period.
While this security review was focused specifically on the PoolTogether V5 Vault implementation, it's necessary to first provide some context on the overall PoolTogether protocol.
PoolTogether V5 is a "no loss lottery" which allows users to deposit a specified asset into ERC4626 vaults where the yield earned (from depositing into protocols such as AAVE and Lido) is awarded to a one of the depositors randomly after specified time periods.
The following six contracts were in scope for this security review:
The PoolTogetherV5 PrizeVault is an ERC4626 Vault implementation which allows users to deposit a specified asset into the vault and receiving shares in return. A users shares in a vault act as tickets in the overall PoolTogether system and their overall share of the assets in a given pool represent their likelihood of winning that pools generated yield when prizes are drawn. The PrizeVault contract inherits both TwabERC20 and Claimable.
PrizeVaultFactory is a relatively stripped back factory implementation that allows anyone to deploy a PrizeVault instance. It is also responsible for keeping track of all the vaults that have been deployed by this factory contract.
TwabERC20 is a relatively simple ERC20 token implementation with the key difference being that it's accounting logic is passed to the TwabController
contract. This is done so that the time weighted average balance of a user can be tracked across the duration of a lottery, ensuring that winners are selected fairly.
Claimable implements the logic that allows an external third party to claim lottery wins on behalf of the winner. The claimPrize
function also allows the winner to perform pre and post claim hooks which as set in HookManager.
HookManager is a contract which allows a specify pre and post claim hooks to be executed when they are the winner of a lottery. The user does this by deploying a smart contract that meets the IVaultHooks interface and calling specifying this contracts address when calling setHooks
.
IVaultHooks is a simple interface outlining the necessary functionality a user created VaultHook should adhere to.
The following roles exist within the system:
The lottery participants are the primary actors in the PrizeVault system. Their main actions revolve around being able to deposit/withdraw yield tokens from the PrizeVault contract. They are also able to set custom hooks to be called when they are the winner of a lottery.
The liquidators primary role is to take convert the yield generated by a PrizeVaults underlying yield vault and convert it into the prizeToken
of a specified prizePool
. In doing this they earn a fee making the liquidator role a profitable opportunity for MEV searchers. While their role has an important impact on the PrizeVault system, the entry point for liquidiators LiquidationPair
is out of scope of this security review.
Claimers are addresses specified by the PrizeVault Owner that are permissioned to claim prizes on behalf of winners.
The owner of the PrizeVault contract is able to set claimer addresses, set the liquidation pair address, set the percentage yield fee and set the recipient of the generated yield fees.
The following diagrams map out the key user facing functionality and give a brief overview of their effects on the contracts state:
Key user actions:
The Claimable contract sets a HOOK_GAS
limit of 150,000 gas for both the before and after claim hooks. However it is reasonable to assume that the most desired use of VaultHooks for a user would be to either withdraw their current shares or deposit the tokens they have won. However from assessing actual withdraw/deposit transactions on the Optimism L2, both calls require around 300-400k gas to complete.
Therefore it would be a reasonble idea for the protocol to allow users to use a maximum of 400,000 gas total between their before and after claim hooks, rather than specifically only allocating half of this to each. Doing so would not increase the damage of gas griefing by users (other than raising the maximum from 150,000 * 2 to 400,000), but give users a lot more freedom to make meaningful calls from their hooks.
PrizeVault generation is trustless and any individual is free to create their own instance of a PrizeVault. However there are currently insufficient checks to help ensure the vault is generated safely (for both the individual deploying the prize vault and the users depositing into it). Making the contract deployment less error prone will increase the usability of the protocol and lower the bar to entry for potential users to deploy their own vault instances.
Potential Improvements:
yieldBuffer_
is non zero.name_
and symbol_
are not empty strings.As PrizeVaults can be deployed trustlessly the main centralisation risks of Vaults are passed on to the deployer rather than the protocol itself. Overall the owner of the vault does not have significant powers to act maliciously after a protocol has been deployed, with the most damaging issue being the ability to raise the yieldFeePercentage
before a liquidation without the users knowledge.
Overall the codebase is relatively easy for an interested developer to pick up and understand, particularly the user facing deposit/withdraw aspects of the protocol. However it should be noted that process of converting yield generated to prize tokens becomes quite confusing and is unclear without concerted developer effort to ascertain the relationship between various contracts such as LiquidiationPair
, Claimable
, PrizeVault
and PrizePool
when it comes to deciding lottery winners, the liquidation of generated yield to prize tokens and payout of prize toens to winners. It would be valuable to both interested users and future security researchers if these steps could be made clearer.
The codebase has complete NATSPEC comments as well as very detailed inline comments explaining any the more unique features of the codebase, such as the yield buffer. On top of this the explainer video provided in the contest overview was clear and gave researchers a good idea of the more complicated aspects of the protocol.
The codebase has a comprehensive test suite which includes fuzzing, invariant testing and integration tests. Furthermroe, the test suite's layout is easy to understand making it easy for external code reviewers to plug in their own edge case tests where necessary. Overall the coverage of the test suite is comprehensive as shown below.
30 hours
#0 - c4-pre-sort
2024-03-13T03:11:07Z
raymondfam marked the issue as high quality report
#1 - c4-pre-sort
2024-03-13T14:18:25Z
raymondfam marked the issue as sufficient quality report
#2 - c4-judge
2024-03-18T04:31:28Z
hansfriese marked the issue as grade-b