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: 63/111
Findings: 3
Award: $59.51
π Selected for report: 0
π Solo Findings: 0
π Selected for report: Udsen
Also found by: 0x11singh99, 0xPsuedoPandit, Daniel526, Darwin, Inspecktor, Jorgect, Nyx, Praise, Tripathi, YY, catellatech, namx05, squeaky_cactus, xuwinnie
19.2867 USDC - $19.29
https://github.com/GenerationSoftware/pt-v5-prize-pool/blob/4bc8a12b857856828c018510b5500d722b79ca3a/src/PrizePool.sol#L299-L306 https://github.com/GenerationSoftware/pt-v5-prize-pool/blob/4bc8a12b857856828c018510b5500d722b79ca3a/src/PrizePool.sol#L278-L280
After frontrunning the setDrawManager
functionAttacker can become drawManager
of the PrizePool
contract if once set then it can not be update by any address so contract and it's reserves funds become vulnerable. After becoming drawManager
attacker can call withdrawReserve
and withdraw all the reserves to his account. He can also call closeDraw
and close the current open draw and open the next one. He updates the number of tiers, the winning random number and the prize pool reserve.
When contract PrizePool.sol
is deployed, there is no address(0) check in constructor for drawManager
so after deploying the contract if address(0) is passed by deployer by mistake. Now deployer will check the event DrawManagerSet(params.drawManager)
, if not emitted he will take action to set DrawManager by calling the function setDrawManager
but here Attacker can frontrun the setDrawManager
function and set the drawManager before deployer. Now no one can change the drawManager
. Attacker can use this for their advantage until tha contract is redeployed.
constructor
and setDrawManager
function.
258: constructor( ConstructorParams memory params ) TieredLiquidityDistributor( params.numberOfTiers, params.tierShares, params.canaryShares, params.reserveShares ) { if (unwrap(params.smoothing) >= unwrap(UNIT)) { revert SmoothingGTEOne(unwrap(params.smoothing)); } prizeToken = params.prizeToken; twabController = params.twabController; smoothing = params.smoothing; claimExpansionThreshold = params.claimExpansionThreshold; drawPeriodSeconds = params.drawPeriodSeconds; _lastClosedDrawStartedAt = params.firstDrawStartsAt; 278: drawManager = params.drawManager; 279: if (params.drawManager != address(0)) { 280: emit DrawManagerSet(params.drawManager); } } /* ============ Modifiers ============ */ /// @notice Modifier that throws if sender is not the draw manager. modifier onlyDrawManager() { if (msg.sender != drawManager) { revert CallerNotDrawManager(msg.sender, drawManager); } _; } /* ============ External Write Functions ============ */ /// @notice Allows a caller to set the DrawManager if not already set. /// @dev Notice that this can be front-run: make sure to verify the drawManager after construction /// @param _drawManager The draw manager function setDrawManager(address _drawManager) external { 300: if (drawManager != address(0)) { 301: revert DrawManagerAlreadySet(); } 303: drawManager = _drawManager; emit DrawManagerSet(_drawManager); 307: }
Manual Review
setDrawManager
function but if deployer want to update drawManager again then add proper access control to setDrawManger
function to avoid this attack.Add owner variable so only deployer can set again not everyone. If deployer does not want to set again the remove setDrawManager function and add only address(0) check in constructor for drawManager before assigning.Access Control
#0 - c4-judge
2023-07-16T22:29:52Z
Picodes marked the issue as duplicate of #356
#1 - c4-judge
2023-08-06T10:32:11Z
Picodes marked the issue as satisfactory
π 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
Number | Issue Details | Instances |
---|---|---|
[L-01] | Unsafe downcasting can result in unwanted number | 2 |
[L-02] | All address should be checked for address(0) in constructor specially for immutables tokens | 1 |
[L-03] | Check receiver address is not address(0) and amount is greater than zero before transferring or minting any tokens | 2 |
Total 3 Low Level Issues
Number | Issue Details | Instances |
---|---|---|
[NC-01] | Named return is confusing, use explicit returns | 2 |
[NC-02] | Use uint256 consistently . Do not mix uint and uint256 both. | 2 |
[NC-03] | Named params of mapping is more readable | 1 |
[NC-04] | Some Contracts are not following proper solidity style guide layout | 1 |
Total 4 Non Critical Issues
2 instances
File: src/libraries/LinearVRGDALib.sol 110: return ud2x18(uint64(uint256(wadExp(maxDiv / 1e18)))); //@audit low unsafe down casting 256 to 64
Recommendation : Use openzeppelin SafeCast library.
File: src/Claimer.sol 72: uint96 feePerClaim = uint96( _computeFeePerClaim( _computeMaxFee(tier, prizePool.numberOfTiers()), claimCount, prizePool.claimCount() ) 77: );
Once immutable set it is fixed if need to update or wrong set then deployer need to redeploy
_prizePool
for address(0) before assigning it to immutable prizePool
File: src/Claimer.sol 37: constructor( PrizePool _prizePool, uint256 _minimumFee, uint256 _maximumFee, uint256 _timeToReachMaxFee, UD2x18 _maxFeePortionOfPrize ) { 44: prizePool = _prizePool;
2 Instances
to
and _prizeRecipient
is not address(0) before transfer and amount is non zeroFile: src/PrizePool.sol 340: _transfer(_to, _amount); ... 473: _transfer(_prizeRecipient, amount);
return from function explicitly make code readable
File: src/Claimer.sol 60: function claimPrizes( Vault vault, uint8 tier, address[] calldata winners, uint32[][] calldata prizeIndices, address _feeRecipient 66: ) external returns (uint256 totalFees) {
File: src/Claimer.sol 66: ) external returns (uint256 totalFees) { ... 82: return feePerClaim * claimCount;
uint256
consistently . Do not mix uint
and uint256
both.File: src/Claimer.sol 103: function computeTotalFees( uint8 _tier, uint _claimCount, uint _claimedCount 107: ) external view returns (uint256) {
File: src/Claimer.sol 120: function computeFeePerClaim(uint256 _maxFee, uint _claimCount) external view returns (uint256)
File: src/PrizePool.sol 206: mapping(address => mapping(address => mapping(uint16 => mapping(uint8 => mapping(uint32 => bool))))) internal claimedPrizes;
In some contract variables are declared after function and state changing functions are after view functions which is not proper code layout of solidity. State variable declaration after functions is not proper code layout of solidity. 1 Instance
File: src/Vault.sol 383: function maxMint(address) public view virtual override returns (uint256) { return _isVaultCollateralized() ? type(uint96).max : 0; } /** * @notice Mint Vault shares to the yield fee `_recipient`. * @dev Will revert if the Vault is undercollateralized * or if the `_shares` are greater than the accrued `_yieldFeeTotalSupply`. * @param _shares Amount of shares to mint * @param _recipient Address of the yield fee recipient */ function mintYieldFee(uint256 _shares, address _recipient) external { _requireVaultCollateralized(); if (_shares > _yieldFeeTotalSupply) revert YieldFeeGTAvailable(_shares, _yieldFeeTotalSupply); _yieldFeeTotalSupply -= _shares; _mint(_recipient, _shares); emit MintYieldFee(msg.sender, _recipient, _shares); 402: }
Recommendation: Update using below guide layout of variables Inside each contract, library or interface, use the following order:
Type declarations State variables Events Errors Modifiers Functions
https://docs.soliditylang.org/en/latest/style-guide.html#order-of-layout
Order of Functionsο Ordering helps readers identify which functions they can call and to find the constructor and fallback definitions easier.
Functions should be grouped according to their visibility and ordered:
constructor receive function (if exists) fallback function (if exists) external public internal private
https://docs.soliditylang.org/en/latest/style-guide.html#order-of-functions
#0 - c4-judge
2023-07-18T19:14:33Z
Picodes marked the issue as grade-b
π Selected for report: c3phas
Also found by: 0x11singh99, 0xAnah, 0xn006e7, LeoS, Rageur, Raihan, ReyAdmirado, Rolezn, SAAJ, SAQ, SM3_SS, Udsen, alymurtazamemon, hunter_w3b, koxuan, naman1778, petrichor, ybansal2403
24.2984 USDC - $24.30
Number | Optimization Details | Instances |
---|---|---|
[G-01] | State variables should be cached in stack variables rather than re-reading them from storage variables | 3 |
[G-02] | Use delete for state variables to reinitialize with their default value rather than assigning default value saves gas. | 3 |
[G-03] | Write for loops in more gas efficient way | 2 |
[G-04] | Using ternary operator instead of if else saves gas | 1 |
[G-05] | Missing zero-address check in constructor | 3 |
[G-06] | Use assembly for address(0) comparison | 1 |
[G-07] | Function calls can be cached rather than re calling from same function with same value will save gas | 2 |
[G-08] | <x> += <y> / <x> -= <y> costs more gas than <x> = <x> + <y> / <x> = <x> - <y> for state variables | 1 |
[G-09] | Use unchecked{} whenever underflow/overflow not possible saves 130 gas each time | 2 |
[G-10] | Do not calculate constant variables | 117 |
[G-11] | Use nested if and, avoid multiple check combinations | 2 |
Total 11 issues
The instances below point to the second+ access of a state variable within a function. Caching of a state variable replaces each Gwarmaccess (100 gas) with a much cheaper stack read
Note: view functions also added since they are used in state changing functions
3 instances - 1 file:
drawManager
can be cached(saves 100 Gas)File : src/PrizePool.sol 287: modifier onlyDrawManager() { 288: if (msg.sender != drawManager) { 289: revert CallerNotDrawManager(msg.sender, drawManager); 290: } 291: _;
lastClosedDrawId + 1
can be cached rather than re-reading and recalculating 3 times(saves 600+ Gas) for Gwarmaccess(100 each) and adding 1 with overflow check 3 times.File : src/PrizePool.sol 316: DrawAccumulatorLib.add( vaultAccumulator[_prizeVault], _amount, 319: lastClosedDrawId + 1, smoothing.intoSD59x18() ); DrawAccumulatorLib.add( totalAccumulator, _amount, 325: lastClosedDrawId + 1, smoothing.intoSD59x18() ); 328: emit ContributePrizeTokens(_prizeVault, lastClosedDrawId + 1, _amount);
_reserve
can be cached rather than reading 3 times.(Saves 200 gas )File : src/PrizePool.sol 336: if (_amount > _reserve) { 337: revert InsufficientReserve(_amount, _reserve); } 339: _reserve -= _amount;
delete
for state variables to reinitialize with their default value rather than assigning default value saves gas.This gas-saving behavior is due to how the delete operation is implemented in the Ethereum Virtual Machine (EVM).
When you use the delete keyword on a state variable, the EVM internally performs a low-level operation called SSTORE to update the variable's storage slot. The SSTORE operation clears the storage slot, effectively resetting the variable to its default value.
In contrast, if you manually assign a default value to a state variable, it involves writing a value to the storage slot. This operation is performed using the SSTORE opcode as well but with an extra cost compared to the delete operation. Writing the default value to the storage slot consumes more gas than simply clearing the storage slot with the delete operation.
3 instances - 1 file:
File : src/PrizePool.sol 369: claimCount = 0; 370: canaryClaimCount = 0; 371: largestTierClaimed = 0;
Recommended Changes:
- 369: claimCount = 0; - 370: canaryClaimCount = 0; - 371: largestTierClaimed = 0; + delete claimCount; + delete canaryClaimCount; + delete largestTierClaimed;
Loop can be written in more gas efficient way, by
2 instance - 1 file:
File : src/Claimer.sol 68: for (uint i = 0; i < winners.length; i++) { 69: claimCount += prizeIndices[i].length; 70: }
Recommended Changes: Convert above for loop to this for making gas efficient saves more than 130 Gas per iteration
- 68: for (uint i = 0; i < winners.length; i++) { 69: claimCount += prizeIndices[i].length; 70: } + uint256 _size= winners.length; + for (uint256 i; i < _size ; ) { claimCount += prizeIndices[i].length; + unchecked { + ++i; + } }
File : src/Claimer.sol 144: for (uint i = 0; i < _claimCount; i++) { 145: fee += _computeFeeForNextClaim( 146: minimumFee, 147: decayConstant, 148: perTimeUnit, 149: elapsed, 150: _claimedCount + i, 151: _maxFee 152: ); 153: }
Recommended Changes: Convert this for loop like above instance#1 recommendation for making gas efficient saves more than 130 Gas per iteration.
When using the if-else construct, Solidity generates bytecode that includes a JUMP operation. The JUMP operation requires the EVM to change the program counter to a different location in the bytecode, resulting in additional gas costs. On the other hand, the ternary operator doesn't involve a JUMP operation, as it generates bytecode that utilizes conditional stack manipulation. The exact amount of gas saved by using the ternary operator instead of the if-else construct in Solidity can vary depending on the specific scenario, the complexity of the surrounding code, and the conditions involved
1 instance - 1 file:
File: src/Claimer.sol 171: if (_tier != _canaryTier) { // canary tier 173: return _computeMaxFee(prizePool.getTierPrizeSize(_canaryTier - 1)); } else { 175: return _computeMaxFee(prizePool.getTierPrizeSize(_canaryTier)); }
Recommended Changes:
- - 171: if (_tier != _canaryTier) { - // canary tier - 173: return _computeMaxFee(prizePool.getTierPrizeSize(_canaryTier - 1)); - } else { - 175: return _computeMaxFee(prizePool.getTierPrizeSize(_canaryTier)); + return + (_tier != _canaryTier) + ? _computeMaxFee(prizePool.getTierPrizeSize(_canaryTier - 1)) + : _computeMaxFee(prizePool.getTierPrizeSize(_canaryTier));
zero-address
check in constructor
Missing checks for zero-addresses may lead to infunctional protocol, if the variable addresses are updated incorrectly. It also waste gas as it requires the redeployment of the contract.
3 instances - 1 file:
prizeToken
,twabController
and drawManager
.File : src/PrizePool.sol 258: constructor( 259: ConstructorParams memory params ) TieredLiquidityDistributor( params.numberOfTiers, params.tierShares, params.canaryShares, params.reserveShares ) { if (unwrap(params.smoothing) >= unwrap(UNIT)) { revert SmoothingGTEOne(unwrap(params.smoothing)); } 271: prizeToken = params.prizeToken; 272: twabController = params.twabController; 273: smoothing = params.smoothing; 274: claimExpansionThreshold = params.claimExpansionThreshold; 275: drawPeriodSeconds = params.drawPeriodSeconds; 276: _lastClosedDrawStartedAt = params.firstDrawStartsAt; 278: drawManager = params.drawManager;
1 instance - 1 file:
File : src/PrizePool.sol 300: if (drawManager != address(0)) { 301: revert DrawManagerAlreadySet(); 302: }
Recommended Changes:
+ bool isAddressZero; + assembly { + isAddressZero := eq(address(drawManager), 0) + } - if (drawManager != address(0)) { + if(!isAddressZero){ revert DrawManagerAlreadySet(); }
2 instances - 1 file:
_openDrawEndsAt()
can be cached.File: src/PrizePool.sol 353: if (block.timestamp < _openDrawEndsAt()) { 354: revert DrawNotFinished(_openDrawEndsAt(), uint64(block.timestamp));
The Solidity compiler does not optimize the <x> += <y> / <x> -= <y> operation for state variables. This means that every time the state variable is updated, the entire value is copied to memory, the operation is performed, and then the value is copied back to storage. This is expensive and can be avoided by using <x> = <x> + <y> / <x> = <x> - <y> instead.
1 instances - 1 file:
File: src/PrizePool.sol - 455: claimerRewards[_feeRecipient] += _fee; + 455: claimerRewards[_feeRecipient] =claimerRewards[_feeRecipient] +_fee;
Because of if condition check before the operation(+/-), it can be decided that underflow/overflow not possible.
2 instances - 1 file:
tierLiquidity.prizeSize - _fee
can't underflow due to line 417 if () condition so they can be unchecked. If that condition is false then only line 459 will run otherwise it will revert from line 418.File: src/PrizePool.sol 417: if (_fee > tierLiquidity.prizeSize) { 418: revert FeeTooLarge(_fee, tierLiquidity.prizeSize); 419: } ... //@audit gas add unchecked{} here - 459: uint256 amount = tierLiquidity.prizeSize - _fee; + 459: unchecked{ + uint256 amount = tierLiquidity.prizeSize - _fee;}
_available
instead of re reading claimerRewards[msg.sender]
again saves another 100 Gas.File: src/PrizePool.sol 483: function withdrawClaimRewards(address _to, uint256 _amount) external { uint256 _available = claimerRewards[msg.sender]; if (_amount > _available) { revert InsufficientRewardsError(_amount, _available); } 490: claimerRewards[msg.sender] -= _amount;
Recommended Changes :
483: function withdrawClaimRewards(address _to, uint256 _amount) external { uint256 _available = claimerRewards[msg.sender]; 486: if (_amount > _available) { revert InsufficientRewardsError(_amount, _available); } - 490: claimerRewards[msg.sender] -= _amount; + 490: unchecked { claimerRewards[msg.sender] = _available - _amount };
Due to how constant variables are implemented (replacements at compile-time), an expression assigned to a constant variable is recomputed each time that the variable is used, which wastes some gas each time of use.
Total 117 instances - 1 files:
File : src/abstract/TieredLiquidityDistributor.sol 84: SD59x18 internal constant TIER_ODDS_0_3 = SD59x18.wrap(2739726027397260); 85: SD59x18 internal constant TIER_ODDS_1_3 = SD59x18.wrap(52342392259021369); ... 200: SD59x18 internal constant TIER_ODDS_14_15 = SD59x18.wrap(1000000000000000000);
Using nested if is cheaper gas wise than using && multiple check combinations. There are more advantages, such as easier to read code and better coverage reports.
2 instances - 1 files:
File : src/PrizePool.sol 794: if ( 795: _nextNumberOfTiers >= _numTiers && canaryClaimCount >= fromUD60x18( intoUD60x18(_claimExpansionThreshold).mul(_canaryPrizeCountFractional(_numTiers).floor()) ) && claimCount >= fromUD60x18( 802: intoUD60x18(_claimExpansionThreshold).mul(toUD60x18(_estimatedPrizeCount(_numTiers))) 803: ) ) { // increase the number of tiers to include a new tier _nextNumberOfTiers = _numTiers + 1; }
Recommended Changes:
- 794: if ( - 795: _nextNumberOfTiers >= _numTiers && - canaryClaimCount >= - fromUD60x18( - intoUD60x18(_claimExpansionThreshold).mul(_canaryPrizeCountFractional(_numTiers).floor()) - ) && - claimCount >= - fromUD60x18( - 802: intoUD60x18(_claimExpansionThreshold).mul(toUD60x18(_estimatedPrizeCount(_numTiers))) - 803: ) - ) { - // increase the number of tiers to include a new tier - _nextNumberOfTiers = _numTiers + 1; - } + if (_nextNumberOfTiers >= _numTiers) { + if ( + canaryClaimCount >= + fromUD60x18( + intoUD60x18(_claimExpansionThreshold).mul( + _canaryPrizeCountFractional(_numTiers).floor() + ) + ) + ) { + if ( + claimCount >= + fromUD60x18( + intoUD60x18(_claimExpansionThreshold).mul( + toUD60x18(_estimatedPrizeCount(_numTiers)) + ) + ) + ) { + // increase the number of tiers to include a new tier + _nextNumberOfTiers = _numTiers + 1; + } + } + }
#0 - c4-judge
2023-07-18T18:58:17Z
Picodes marked the issue as grade-b
#1 - PierrickGT
2023-09-07T21:44:23Z
Gβ01:
G-02: code is more legible when assigning 0
than using delete
G-03:
_winners.length
cause otherwise we would have to use via-irG-04: has been removed: https://github.com/GenerationSoftware/pt-v5-claimer/blob/7b62fb8c7e40b08631813eec4163a866c3a313bc/src/Claimer.sol#L286 G-05: has been fixed for DrawManager: https://github.com/GenerationSoftware/pt-v5-prize-pool/blob/328fd4939ae585a90f7dd0ed91c5a74bbbede9c4/src/PrizePool.sol#L311 The PrizePool code is already pretty big, so we won't check for address zero to be able to compile without via-ir in the future. G-06: we would lose in code legibility. G-07: would only save gas if we enter in the condition and the transaction would revert anyway. G-08: we would lose in code legibility.
G-09:
G-10: there is not calculation here, we simply assign a value. G-11: has been removed