Platform: Code4rena
Start Date: 23/06/2023
Pot Size: $60,500 USDC
Total HM: 31
Participants: 132
Period: 10 days
Judge: 0xean
Total Solo HM: 10
Id: 254
League: ETH
Rank: 40/132
Findings: 3
Award: $227.58
🌟 Selected for report: 1
🚀 Solo Findings: 0
80.4648 USDC - $80.46
The governance system might work unexpectedly because users will get the wrong forVotes
and againstVotes
for the proposal.
proposals()
shows the current status of the proposal.
function proposals(uint256 proposalId) external view returns (uint256 id, address proposer, uint256 eta, uint256 startBlock, uint256 endBlock, uint256 forVotes, uint256 againstVotes, uint256 abstainVotes, bool canceled, bool executed) { id = proposalId; eta = proposalEta(proposalId); startBlock = proposalSnapshot(proposalId); endBlock = proposalDeadline(proposalId); proposer = proposalProposer(proposalId); forVotes = proposalData[proposalId].supportVotes[0]; //swapped forVotes, againstVotes againstVotes = proposalData[proposalId].supportVotes[1]; abstainVotes = proposalData[proposalId].supportVotes[2]; ProposalState currentState = state(proposalId); canceled = currentState == ProposalState.Canceled; executed = currentState == ProposalState.Executed; }
It outputs supportVotes[0]
as forVotes
and supportVotes[1]
as againstVotes
.
But when we check _voteSucceeded()
, supportVotes[1]
should be forVotes
to work properly.
function _voteSucceeded(uint256 proposalId) internal view override returns (bool){ return proposalData[proposalId].supportVotes[1] > proposalData[proposalId].supportVotes[0]; }
While comparing with other governance protocols, it's common to set supportVotes[0]
as againstVotes
and supportVotes[1]
as forVotes
.
So proposals()
returns the swapped forVotes/againstVotes
and I submit it as a medium risk as users will use this view function to check the proposal status and interact.
Manual Review
In proposals()
, supportVotes[0]
should be againstVotes
and supportVotes[1]
shou8ld be forVotes
.
Governance
#0 - c4-pre-sort
2023-07-04T02:21:34Z
JeffCX marked the issue as duplicate of #15
#1 - c4-judge
2023-07-28T15:33:02Z
0xean marked the issue as satisfactory
#2 - c4-judge
2023-07-28T19:41:26Z
0xean changed the severity to 3 (High Risk)
37.7738 USDC - $37.77
Users can withdraw their staking token immediately after charging more rewards using boost.
withdraw()
should prevent withdrawals during the boost lock but there is no such logic.
The below steps show how users can charge more rewards without locking their funds.
function withdraw(uint256 _amount) external updateReward(msg.sender) { require(_amount > 0, "amount = 0"); balanceOf[msg.sender] -= _amount; totalSupply -= _amount; stakingToken.transfer(msg.sender, _amount); emit WithdrawToken(msg.sender, _amount, block.timestamp); }
// Calculates and returns the earned rewards for a user function earned(address _account) public view returns (uint256) { return ((balanceOf[_account] * getBoost(_account) * (rewardPerToken() - userRewardPerTokenPaid[_account])) / 1e38) + rewards[_account]; }
Manual Review
withdraw()
should check the boost lock like this.
function withdraw(uint256 _amount) external updateReward(msg.sender) { require(block.timestamp >= esLBRBoost.getUnlockTime(msg.sender), "Your lock-in period has not ended."); require(_amount > 0, "amount = 0"); balanceOf[msg.sender] -= _amount; totalSupply -= _amount; stakingToken.transfer(msg.sender, _amount); emit WithdrawToken(msg.sender, _amount, block.timestamp); }
Invalid Validation
#0 - c4-pre-sort
2023-07-11T20:20:57Z
JeffCX marked the issue as primary issue
#1 - c4-sponsor
2023-07-14T08:48:24Z
LybraFinance marked the issue as sponsor acknowledged
#2 - c4-judge
2023-07-25T22:56:34Z
0xean marked the issue as satisfactory
#3 - c4-judge
2023-07-28T13:08:21Z
0xean changed the severity to 2 (Med Risk)
#4 - c4-judge
2023-07-28T20:35:55Z
0xean marked the issue as selected for report
37.7738 USDC - $37.77
The boost calculation is incorrect after the rewardRatio
was changed.
getUserBoost()
calculates the boost factor based on the lock status.
function getUserBoost(address user, uint256 userUpdatedAt, uint256 finishAt) external view returns (uint256) { uint256 boostEndTime = userLockStatus[user].unlockTime; uint256 maxBoost = userLockStatus[user].miningBoost; if (userUpdatedAt >= boostEndTime || userUpdatedAt >= finishAt) { return 0; } if (finishAt <= boostEndTime || block.timestamp <= boostEndTime) { return maxBoost; } else { uint256 time = block.timestamp > finishAt ? finishAt : block.timestamp; return ((boostEndTime - userUpdatedAt) * maxBoost) / (time - userUpdatedAt); } }
Within the else
block, it calculates the boost factor linearly but it would be wrong after the rewardRatio
was changed in the pool.
stakerewardV2pool
, rewardRatio = 1
at time 0. finishAt = 100(sec)
.esLBRBoost
, Alice's unlockTime = 100(sec), miningBoost = 100(%)
.stakerewardV2pool.notifyRewardAmount()
was called by owner and rewardRatio = 2, finishAt = 200(sec)
.With the above assumptions, let's compare the two rewards withdrawal approaches of Alice.
stakerewardV2pool
, Alice's pure rewards will be 1 * 100 + 2 * 100 = 300.In getUserBoost()
, boostEndTime = 100, maxBoost = 100, finishAt = 200, block.timestamp = 200, userUpdatedAt = 0
function getUserBoost(address user, uint256 userUpdatedAt, uint256 finishAt) external view returns (uint256) { uint256 boostEndTime = userLockStatus[user].unlockTime; uint256 maxBoost = userLockStatus[user].miningBoost; if (userUpdatedAt >= boostEndTime || userUpdatedAt >= finishAt) { return 0; } if (finishAt <= boostEndTime || block.timestamp <= boostEndTime) { return maxBoost; } else { uint256 time = block.timestamp > finishAt ? finishAt : block.timestamp; return ((boostEndTime - userUpdatedAt) * maxBoost) / (time - userUpdatedAt); } }
Then the boost factor will be ((boostEndTime - userUpdatedAt) * maxBoost) / (time - userUpdatedAt) = (100 - 0) * 100 / (200 - 0) = 50
.
So Alice's final rewards in earned() will be 300 * (100% + 50%) = 450
.
2. Alice claims the rewards two times at time 100 and 200. At time 100, her pure rewards will be 1 * 100 = 100
and the boost factor will be 100. So 200 at time 100.
And at time 200, her pure rewards will be 2 * 100 = 200
and her boost factor will be 0 as it's ended already.
So her final reward will be 200 + 200 = 400.
The rewards are different because getUserBoost()
assumes the reward ratio won't be changed between userUpdatedAt
and the current time.
Manual Review
I don't have a simple approach to mitigate this issue. I think we might add some requirements to notifyRewardAmount()
so that the rewardRatio
isn't updated during the boost period.
Math
#0 - c4-pre-sort
2023-07-10T23:56:06Z
JeffCX marked the issue as primary issue
#1 - c4-pre-sort
2023-07-13T13:38:16Z
JeffCX marked the issue as duplicate of #838
#2 - c4-judge
2023-07-28T13:06:50Z
0xean marked the issue as duplicate of #773
#3 - c4-judge
2023-07-28T15:38:23Z
0xean marked the issue as satisfactory
109.3508 USDC - $109.35
https://github.com/code-423n4/2023-06-lybra/blob/5d70170f2c68dbd3f7b8c0c8fd6b0b2218784ea6/contracts/lybra/configuration/LybraConfigurator.sol#L339 https://github.com/code-423n4/2023-06-lybra/blob/5d70170f2c68dbd3f7b8c0c8fd6b0b2218784ea6/contracts/lybra/pools/base/LybraPeUSDVaultBase.sol#L128
LybraPeUSDVaultBase.liquidation()
will revert due to underflow when vaultBadCollateralRatio[pool]
and vaultBadCollateralRatio[pool]
are 0.
getBadCollateralRatio()
returns the bad collateral ratio to be used during the liquidation.
function getSafeCollateralRatio( address pool ) external view returns (uint256) { if (vaultSafeCollateralRatio[pool] == 0) return 160 * 1e18; return vaultSafeCollateralRatio[pool]; } function getBadCollateralRatio(address pool) external view returns(uint256) { if(vaultBadCollateralRatio[pool] == 0) return vaultSafeCollateralRatio[pool] - 1e19; return vaultBadCollateralRatio[pool]; }
As we can see from getSafeCollateralRatio()
, vaultSafeCollateralRatio[pool]
might be 0 and we use the default 160 * 1e18
for that case.
But there is such logic in getBadCollateralRatio()
and it will revert due to underflow when vaultBadCollateralRatio[pool] == 0 && vaultSafeCollateralRatio[pool] == 0
.
Then LybraPeUSDVaultBase.liquidation()
will revert as it calls getBadCollateralRatio
inside and the liquidation logic wouldn't work at all.
Manual Review
We should modify getBadCollateralRatio()
like this.
function getBadCollateralRatio(address pool) external view returns(uint256) { if(vaultBadCollateralRatio[pool] == 0) return vaultSafeCollateralRatio[pool] == 0 ? 150 * 1e18 : vaultSafeCollateralRatio[pool] - 1e19; return vaultBadCollateralRatio[pool]; }
Under/Overflow
#0 - c4-pre-sort
2023-07-09T14:34:12Z
JeffCX marked the issue as duplicate of #926
#1 - c4-judge
2023-07-28T15:35:26Z
0xean marked the issue as satisfactory