Platform: Code4rena
Start Date: 03/02/2022
Pot Size: $75,000 USDC
Total HM: 42
Participants: 52
Period: 7 days
Judge: GalloDaSballo
Total Solo HM: 21
Id: 83
League: ETH
Rank: 4/52
Findings: 9
Award: $5,061.62
🌟 Selected for report: 2
🚀 Solo Findings: 0
1769.9964 USDC - $1,770.00
https://github.com/code-423n4/2022-02-concur/blob/main/contracts/MasterChef.sol#L170-L172
If a pool’s deposit fee is non-zero, it is subtracted from the amount to be credited to the user.
if (pool.depositFeeBP > 0) { uint depositFee = _amount.mul(pool.depositFeeBP).div(_perMille); user.amount = SafeCast.toUint128(user.amount + _amount - depositFee); }
However, the deposit fee is not credited to anyone, leading to permanent lockups of deposit fees in the relevant depositor contracts (StakingRewards and ConvexStakingWrapper for now).
Assume the following
pid = 1
in the convex booster contract.depositFeeBP = 100 (10%)
.deposits
mapping of the ConvexStakingWrapper contract credits 1000 LP tokens to her.requestWithdraw()
for 1000 LP tokens, attempts to execute withdraw()
with amount = 1000 will revert because she is only credited 900 LP tokens in the Masterchef contract.depositFeeBP = 100 (10%)
.These examples are non-exhaustive as more depositors can be added / removed from the Masterchef contract.
I recommend shifting the deposit fee logic out of the masterchef contract into the depositor contracts themselves, as additional logic would have to be added in the masterchef to update the fee recipient’s state (rewardDebt, send pending concur rewards, update amount), which further complicates matters. As the fee recipient is likely to be the treasury, it is also not desirable for it to accrue concur rewards.
if (pool.depositFeeBP > 0) { uint depositFee = _amount.mul(pool.depositFeeBP).div(_perMille); user.amount = SafeCast.toUint128(user.amount + _amount - depositFee); UserInfo storage feeRecipient = userInfo[_pid][feeRecipient]; // TODO: update and send feeRecipient pending concur rewards feeRecipient.amount = SafeCast.toUint128(feeRecipient.amount + depositFee); // TODO: update fee recipient's rewardDebt }
#0 - GalloDaSballo
2022-04-08T13:41:10Z
The warden has identified a way for funds to be forever lost, because of that reason I believe High Severity to be appropriate.
Mitigation could be as simple as transferring the fee to a feeReceiver
or adding a way to pull those fees
🌟 Selected for report: mtz
Also found by: 0x1f8b, Czar102, GalloDaSballo, GeekyLumberjack, Randyyy, Rhynorater, Ruhum, ShadowyNoobDev, bitbopper, cccz, cmichel, csanuragjain, danb, gzeon, hickuphh3, hyh, leastwood
31.0722 USDC - $31.07
https://github.com/code-423n4/2022-02-concur/blob/main/contracts/Shelter.sol#L55
withdraw()
assigns sets claimed
to true
for the desired destination address. This means a user can make multiple claims by withdrawing to multiple addresses.
Change _to
to msg.sender
.
claimed[_token][msg.sender] = true;
#0 - r2moon
2022-02-18T01:53:29Z
#1 - GalloDaSballo
2022-04-19T00:11:57Z
Duplicate of #246
🌟 Selected for report: WatchPug
Also found by: CertoraInc, bobi, csanuragjain, danb, hickuphh3, leastwood
https://github.com/code-423n4/2022-02-concur/blob/main/contracts/MasterChef.sol#L159
The intended party for crediting the deposit to should be the _recipient
, but it is selected as _msgSender()
, which would be the depositor contracts (ConvexStakingWrapper
and StakingRewards
).
UserInfo storage user = userInfo[_pid][_msgSender()];
This means that everyone’s amounts are grouped together, breaking reward distribution calculations.
Switch _msgSender()
to _receipient
.
UserInfo storage user = userInfo[_pid][_receipient];
#0 - r2moon
2022-02-18T02:56:27Z
🌟 Selected for report: WatchPug
Also found by: cmichel, harleythedog, hickuphh3, kirk-baird, leastwood
387.0982 USDC - $387.10
https://github.com/code-423n4/2022-02-concur/blob/main/contracts/ConvexStakingWrapper.sol#L175-L182 https://github.com/code-423n4/2022-02-concur/blob/main/contracts/ConvexStakingWrapper.sol#L201-L204
bal = IERC20(reward.token).balanceOf(address(this));
is not updated after making token transfers to the claimContract
(and treasury if reward token is CRV / CVX). Hence, reward.remaining
will be incorrectly updated to bal
, causing subsequent _calcRewardIntegral()
calls to fail while the reward token balance does not exceed the old balance.
if (bal != reward.remaining) { reward.remaining = uint128(bal); }
bal
should be decremented by the transfer amounts after transfers to the claimContract (and treasury), like how it’s done in the original contract.
if (reward.token == cvx || reward.token == crv) { uint256 treasuryFee = d_reward / 5; IERC20(reward.token).transfer(treasury, treasuryFee); bal -= treasuryFee; d_reward -= treasuryFee; } IERC20(reward.token).transfer(address(claimContract), d_reward); bal -= dReward;
#0 - GalloDaSballo
2022-04-20T15:33:52Z
Dup of #199
https://github.com/code-423n4/2022-02-concur/blob/main/contracts/MasterChef.sol#L117 https://github.com/code-423n4/2022-02-concur/blob/main/contracts/MasterChef.sol#L140
The lpSupply
is derived from the Masterchef’s deposit token balance.
lpSupply = pool.depositToken.balanceOf(address(this));
However, it is important to note that there is no stake token transfer for deposits and withdrawals, so lpSupply()
is expected to be zero (unless someone sends some deposit tokens to the contract). As a result, there is no reward accrual for all users.
// snippet from updatePool() if (lpSupply == 0 || pool.allocPoint == 0) { pool.lastRewardBlock = block.number; return; }
Save lpSupply
in the PoolInfo
struct. Increment and decrement the variable for deposits / withdrawals. Note that the deposit fee should be excluded from the increment because it’s not credited to anyone.
struct PoolInfo { IERC20 depositToken; // Address of LP token contract. uint allocPoint; // How many allocation points assigned to this pool. to distribute per block. uint lastRewardBlock; // Last block number that distribution occurs. uint accConcurPerShare; // Accumulated per share, times multiplier. See below. uint lpSupply; // total amount of LP tokens supplied. Internal accounting is done because contract does not actually hold the LP tokens uint16 depositFeeBP; // Deposit fee in basis points } // TODO: replace line (or remove) below with pool.lpSupply uint lpSupply = pool.depositToken.balanceOf(address(this)); // Eg. in pendingConcur() accConcurPerShare = accConcurPerShare.add(concurReward.mul(_concurShareMultiplier).div(pool.lpSupply)); function deposit() { ... if (_amount > 0) { if (pool.depositFeeBP > 0) { uint depositFee = _amount.mul(pool.depositFeeBP).div(_perMille); user.amount = SafeCast.toUint128(user.amount + _amount - depositFee); // depositFee is excluded because portion of reward would've been unaccounted for pool.lpSupply += _amount - depositFee; } else { user.amount = SafeCast.toUint128(user.amount + _amount); pool.lpSupply += _amount; } } ... } function withdraw() { ... if (_amount > 0) { user.amount = SafeCast.toUint128(user.amount - _amount); pool.lpSupply -= _amount; } ... }
#0 - r2moon
2022-02-18T02:58:49Z
#1 - GalloDaSballo
2022-04-18T23:38:12Z
Duplicate of #200
🌟 Selected for report: Czar102
Also found by: harleythedog, hickuphh3, throttle
https://github.com/code-423n4/2022-02-concur/blob/main/contracts/MasterChef.sol#L86-L101
It is possible to add a pool with > 100% deposit fee. Given that there is no way to update the fee, and that a pool can only be registered once, an incorrect value set would cause deposits to fail.
Side note: the deposit fee is improperly handled (see other issue submitted).
Have a sanity check to ensure the deposit fee is ≤ 100%.
function add(address _token, uint _allocationPoints, uint16 _depositFee, uint _startBlock) public onlyOwner { // note: depositFeeBP and _perMille are in different denominations, mentioned in separate issue require(_depositFee <= _perMille, "depositFee exceeds maximum value"); ... }
#0 - r2moon
2022-02-18T03:01:00Z
#1 - GalloDaSballo
2022-04-08T00:30:42Z
Duplicate of #242
🌟 Selected for report: leastwood
Also found by: CertoraInc, Czar102, WatchPug, csanuragjain, hickuphh3, kirk-baird
https://github.com/code-423n4/2022-02-concur/blob/main/contracts/MasterChef.sol#L145-L148
The pool’s accConcurPerShare
ceases to update once the block number exceeds the endBlock
. This means that there is potentially a period between the last accounted block and end block that fails to update accConcurPerShare
.
endBlock = 5000
, pool.lastRewardBlock = 4900
updatePool()
is called is when block.number = 5001
The 1st and 3rd if blocks in updatePool()
can be combined.
function updatePool(uint _pid) public { PoolInfo storage pool = poolInfo[_pid]; // take minimum of endBlock and current block number uint lastBlockToReward = endBlock > block.number ? block.number : endBlock; // only need to update state if pool.lastRewardBlock < lastBlockToReward if (lastBlockToReward <= pool.lastRewardBlock) return; ... // if(block.number >= endBlock) { // pool.lastRewardBlock = block.number; // return; // } ... }
#0 - r2moon
2022-02-18T02:06:54Z
#1 - GalloDaSballo
2022-04-20T00:42:30Z
Dup of #107
🌟 Selected for report: hickuphh3
Also found by: 0x1f8b, 0xw4rd3n, BouSalman, CertoraInc, Czar102, Dravee, IllIllI, Randyyy, Rhynorater, Ruhum, ShadowyNoobDev, Sleepy, SolidityScan, WatchPug, bitbopper, cccz, cryptphi, csanuragjain, defsec, gzeon, harleythedog, hubble, hyh, kenta, kirk-baird, leastwood, mtz, pauliax, peritoflores, rfa, robee, samruna, throttle, wuwe1, ye0lde
1221.8001 USDC - $1,221.80
Overall, code quality was fair. A number of contracts were taken from various sources, such as StakingRewards, Masterchef and the ConvexStakingWrapper. Modifications were made to include custom features like taking a 20% fee on CVX and CRV rewards for the treasury, and to not require stake token transfers for deposits / withdrawals into the Masterchef contract.
I found 10
high severity issues, majority of which are found in the Masterchef contract. They were simple logic bugs that would have been discovered with unit tests.
In addition, I made 2
medium severity, 7
low severity and 1
non-critical findings.
Note that during the contest, an example shelter client was added and pushed to a new branch for wardens to understand how the shelter would operate. The integration of the ConvexStakingWrapper with the Shelter in that branch has a few bugs, but I assume it is outside the current contest scope to report them.
Due to the number of issues raised, I strongly recommend the team to write unit tests for their contracts, and to consider running a mitigation contest.
https://github.com/code-423n4/2022-02-concur/blob/main/contracts/MasterChef.sol#L113-L124
Even though rewards distribution cease after endBlock
, pendingConcur()
will calculate as if reward distribution has not.
Distribution of rewards will cease after endBlock
, but pendingConcur()
will show increasing pending rewards because it does not account for endBlock
.
function pendingConcur(uint _pid, address _user) external view returns (uint) { ... // take the minimum of endBlock and currentBlock uint endRewardBlock = endBlock >= block.number ? block.number : endBlock; if (endRewardBlock > pool.lastRewardBlock && lpSupply != 0) { uint multiplier = getMultiplier(pool.lastRewardBlock, endRewardBlock); ... } ... }
https://github.com/code-423n4/2022-02-concur/blob/main/contracts/MasterChef.sol#L52-L53
uint public endBlock; // The block number when mining starts.
is incorrect, as it should be the end of the mining period, not the start. Its comment applies to startBlock
.
Note that uint public startBlock
does not have a comment. Consider adding it.
uint public startBlock; // The block number when mining starts. uint public endBlock; // The block number when mining ends.
safeConcurTransfer()
potentially reverts for zero amounthttps://github.com/code-423n4/2022-02-concur/blob/main/contracts/MasterChef.sol#L205-L210
If the contract has zero concur tokens, the following may revert because of zero amount. This is of course dependent on the concur token implementation.
// couple of lines omitted transferSuccess = concur.transfer(_to, concurBalance); require(transferSuccess, "safeConcurTransfer: transfer failed");
_allocationPoints
if pool added after endBlock in add()
While a pool can (and should be allowed) to be added after endBlock
, no rewards can be distributed after endBlock
. Hence, it would be good to ensure _allocationPoints
is zero when the pool is added, because a non-zero value comes with the expectation that the pool will be receiving rewards (worse still, have concur tokens transferred to the contract, which will be permanently locked).
// in add() if (block.number > endBlock) require(_allocationPoints == 0, “mining period ended”);
_calcRewardIntegral()
https://github.com/code-423n4/2022-02-concur/blob/main/contracts/ConvexStakingWrapper.sol#L179-L180
The treasury takes a 20% fee of rewards. The calculation will possibly leave 1 wei unaccounted for.
IERC20(reward.token).transfer(treasury, d_reward / 5); d_reward = (d_reward * 4) / 5;
For instance, assume d_reward = 21
. The treasury receives 4
wei while the user receives 16
wei, leaving 1 wei unaccounted for.
uint256 rewardFee = d_reward / 5; IERC20(reward.token).transfer(treasury, rewardFee); d_reward -= rewardFee;
https://github.com/code-423n4/2022-02-concur/blob/main/contracts/USDMPegRecovery.sol#L100
The README says “Once 40m USDM is deposited, 3Crv side of the contract starts accepting deposits.” However, the code accepts 3CRV deposits after 4M USDM is deposited instead.
Specify the threshold as an internal constant, and use underscores for readability. I also recommend double-checking the values of declared variables in all contracts, such as step
and concurPerBlock
.
uint256 internal constant MIN_USDM_AMOUNT = 40_000_000e18; require(totalLiquidity.usdm > MIN_USDM_AMOUNT, "usdm low"); // or require(totalLiquidity.usdm > 40_000_000e18, "usdm low");
setRewardsDistribution()
https://github.com/code-423n4/2022-02-concur/blob/main/contracts/StakingRewards.sol#L191-L194
setRewardsDistribution()
has the following check:
require( block.timestamp > periodFinish, "Previous rewards period must be complete before changing the duration for the new period" );
The statement is incorrect because it’s rewardsDistribution
that is being changed, not the rewards duration.
Actually, the check is redundant, because there is no harm changing rewardsDistribution
while distribution is ongoing. I suggest removing the check entirely. Otherwise, change the comment to
"Previous rewards period must be complete before changing rewardsDistribution"
https://github.com/code-423n4/2022-02-concur/blob/main/contracts/MasterChef.sol#L25
Rename RADSs
to Concurs
#0 - GalloDaSballo
2022-04-26T20:33:32Z
L01: Masterchef: pendingConcur() shows increasing reward amounts after mining period ends Valid finding
L02: Masterchef: Incorrect comment on endBlock Non-critical IMO
L03: Masterchef: safeConcurTransfer() potentially reverts for zero amount I don't believe it will cause issues, but think 0 check is low per industry standard
L04: Masterchef: Ensure 0 _allocationPoints if pool added after endBlock in add() Disagree as adding after end will give them no point anyway and the warden didn't prove a way to sidestep that
L05: ConvexStakingWrapper: Small rounding error in _calcRewardIntegral() After further consideration I agree
L06: USDMPegRecovery: 40M or 4M threshold? I feel like this is the only case where I'd give low vs non-critical as the comment and the code have a meaningful, and significant difference for the end users
L07: StakingRewards: Incorrect revert statement in setRewardsDistribution() Disagree with severity
NC01: Masterchef: RADSs → Concurs Valid finding
Report has plenty of content, formatting is good, I think most findings are over-emphasized though and under further scrutiny this is basically equivalent to 4 findings
#1 - GalloDaSballo
2022-04-26T20:34:49Z
Adding #137 does make the report more well rounded and adding #136 makes this the most interesting report thus far, 6.5 findings at this time
#2 - GalloDaSballo
2022-04-27T15:00:29Z
6++ with very good formatting
#3 - GalloDaSballo
2022-04-28T21:20:45Z
L01: Masterchef: pendingConcur() shows increasing reward amounts after mining period ends Low
L02: Masterchef: Incorrect comment on endBlock Non-Critical
L03: Masterchef: safeConcurTransfer() potentially reverts for zero amount Low
L05: ConvexStakingWrapper: Small rounding error in _calcRewardIntegral() Low
L06: USDMPegRecovery: 40M or 4M threshold? Low
L07: StakingRewards: Incorrect revert statement in setRewardsDistribution() Non Critical
NC01: Masterchef: RADSs → Concurs Non-Critical
#137 -> Non-critical
#136 -> Low Severity
#4 - liveactionllama
2022-05-23T20:12:32Z
Just a note that C4 is excluding any invalid entries from the official report.
🌟 Selected for report: WatchPug
Also found by: 0x0x0x, 0x1f8b, 0x510c, 0xNot0rious, 0xngndev, BouSalman, CertoraInc, Dravee, Heartless, IllIllI, Jujic, Randyyy, Ruhum, ShadowyNoobDev, Sleepy, SolidityScan, Tomio, bitbopper, csanuragjain, defsec, gzeon, hickuphh3, kenta, mtz, pauliax, peritoflores, rfa, robee, sabtikw, throttle, wuwe1, ye0lde
85.3051 USDC - $85.31
The following variables / events are initialized / declared, but their values are not used in the contract subsequently. Consider removing them.
USDMPegRecovery.startLiquidity
The following variables can be made immutable or constant.
Masterchef._concurShareMultiplier
Solidity 0.8 comes with in-built integer flow checks. The use of SafeMath
for uint
is therefore unnecessary.
safeConcurTransfer()
function safeConcurTransfer(address _to, uint _amount) private { uint concurBalance = concur.balanceOf(address(this)); bool transferSuccess = false; if (_amount > concurBalance) { transferSuccess = concur.transfer(_to, concurBalance); } else { transferSuccess = concur.transfer(_to, _amount); } require(transferSuccess, "safeConcurTransfer: transfer failed"); }
can be simplified to
function safeConcurTransfer(address _to, uint _amount) private { uint concurBalance = concur.balanceOf(address(this)); uint amountToTransfer = _amount >= concurBalance ? concurBalance : _amount; require( concur.transfer(_to, amountToTransfer), "safeConcurTransfer: transfer failed" ); }
provide()
https://github.com/code-423n4/2022-02-concur/blob/main/contracts/USDMPegRecovery.sol#L74-L76
Instead of fetching the contract’s USDM balance twice, it would save gas to store the result in a local variable and reuse it.
uint usdmBal = usdm.balanceOf(address(this)); require(usdmBal >= totalLiquidity.usdm, "<liquidity"); // truncate amounts under step uint256 addingLiquidity = (usdmBal / step) * step;
IRewardStaking(extraPool).rewardToken()
with extraToken
https://github.com/code-423n4/2022-02-concur/blob/main/contracts/ConvexStakingWrapper.sol#L123
https://github.com/code-423n4/2022-02-concur/blob/main/contracts/ConvexStakingWrapper.sol#L131
Since IRewardStaking(extraPool).rewardToken()
has already been fetched once and saved in extraToken
, gas can be saved by using it instead of fetching the value again.
rewards[_pid].push( RewardType({ token: extraToken, pool: extraPool, integral: 0, remaining: 0 }) );
#0 - GalloDaSballo
2022-04-03T15:41:07Z
G01: Redundant variables / events Finding is valid, this would save deployment gas cost.
G02: Immutable / constant variables Agree with finding, because the warden didn't show exactly how the gas would have been saved I'll give one cold SLOAD per variable 7 * 2100 14700
G03: Masterchef: Redundant need for SafeMath No explicit report as such 0
G04: Masterchef: Simplify safeConcurTransfer() Will save gas on require, 6 gas
G05: USDMPegRecovery: save contract’s usdm balance in provide() Agree, 100 gas for STATICCALL + 100 for hot read
G06: ConvexStakingWrapper: Replace IRewardStaking(extraPool).rewardToken() with extraToken Will save 100 + 100 gas
Great little compact report, with nice formatting and links to the findings
15106 gas saved