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: 13/132
Findings: 6
Award: $841.64
π Selected for report: 1
π Solo Findings: 0
650.0045 USDC - $650.00
https://github.com/code-423n4/2023-06-lybra/blob/main/contracts/lybra/miner/EUSDMiningIncentives.sol#L132-L134 https://github.com/code-423n4/2023-06-lybra/blob/main/contracts/lybra/miner/EUSDMiningIncentives.sol#L136-L147
The EUSDMiningIncentives contract is intended to function similarly to the Synthetix staking rewards contract. This means that the rewards per second, defined as rewardRatio
, which is set in the notifyRewardAmount
function, is supposed to be distributed to users as an equivalent percentage of how much the user has staked as compared to the total amount staked. In this contract, the total amount staked is equal to the total supply of EUSD tokens. However, the calculated amount staked PER user is equal to the total amount borrowed of tokens (EUSD and PeUSD) across ALL vaults. This means that the amount returned by the totalStaked
function is wrong, as it should also include the total supply of all the vaults which are included in the pools
array (EUSD and PeUSD). This will effectively result in much more than the intended amount of rewards to be minted, as the numerator (total amount of EUSD and PeUSD) across all users is much more than the denominator (total amount of EUSD).
First consider the stakedOf
function, which sums up the borrowed amount across all vaults in the pools
array (both EUSD and PeUSD):
function stakedOf(address user) public view returns (uint256) { uint256 amount; for (uint i = 0; i < pools.length; i++) { ILybra pool = ILybra(pools[i]); uint borrowed = pool.getBorrowedOf(user); if (pool.getVaultType() == 1) { borrowed = borrowed * (1e20 + peUSDExtraRatio) / 1e20; } amount += borrowed; } return amount; }
Then consider the totalStaked
function, which just returns the total supply of EUSD:
function totalStaked() internal view returns (uint256) { return EUSD.totalSupply(); }
The issue arrises in the earned
function which references both the stakedOf
value and the totalSupply
value:
function earned(address _account) public view returns (uint256) { // @note - read return ((stakedOf(_account) * getBoost(_account) * (rewardPerToken() - userRewardPerTokenPaid[_account])) / 1e38) + rewards[_account]; }
Here, stakedOf
(which includes EUSD and PeUSD), is multiplied by a call to rewardPerToken
minus the old user reward debt. This function has totalStaked()
in the denominator, which is where this skewed calculation is occurring:
function rewardPerToken() public view returns (uint256) { ... return rewardPerTokenStored + (rewardRatio * (lastTimeRewardApplicable() - updatedAt) * 1e18) / totalStaked(); }
This will effectively result in much more than the intended amount of rewards to be minted to the users, which will result in the supply of esLBR inflating much faster than intended.
Manual review
The totalStaked
function should be updated to sum up the totalSupply of EUSD and all the PeUSD vaults which are in the pools
array.
Math
#0 - c4-pre-sort
2023-07-10T20:59:26Z
JeffCX marked the issue as primary issue
#1 - c4-sponsor
2023-07-18T06:36:39Z
LybraFinance marked the issue as sponsor confirmed
#2 - c4-judge
2023-07-26T12:46:21Z
0xean marked the issue as satisfactory
#3 - c4-judge
2023-07-28T20:37:14Z
0xean marked the issue as selected for report
29.0567 USDC - $29.06
https://github.com/code-423n4/2023-06-lybra/blob/main/contracts/lybra/miner/EUSDMiningIncentives.sol#L181 https://github.com/code-423n4/2023-06-lybra/blob/main/contracts/lybra/miner/ProtocolRewardsPool.sol#L88 https://github.com/code-423n4/2023-06-lybra/blob/main/contracts/lybra/miner/stakerewardV2pool.sol#L102 https://github.com/code-423n4/2023-06-lybra/blob/main/contracts/lybra/miner/esLBRBoost.sol#L38-L45
The boosting logic of the esLBRBoost contract is not utilized properly in any of the rewards contracts (EUSDMiningIncentives, ProtocolRewardsPool, stakerewardV2pool). This can effectively result in users gaming the boosting functionality to (1) maximize their rewards, and (2) minimize their staking time, with absolutely no downside.
More specifically, the unstake
function of the ProtocolRewardsPool contract is intended to check whether a user has waited the entire unlock time for the boost settings they have selected. However, there is no boost incentive, meaning a user can simply select the lowest lock period to get around this check. Additionally, they can set their lock settings much earlier than when they stake, which would allow them to skip this check entirely.
In the getBoost
functions of the EUSDMiningIncentives and stakerewardV2pool, a user effectively receives extra rewards for having a boost setting with a longer unlock time. However, there is no downside for a user simply selecting the longest unlock time setting, as there's no logic in either of these contracts which enforce that the user actually stakes their tokens for that period.
Since there are issues across all of the rewards contracts, let's look at the issue with how boosting is used in the stakerewardV2pool contract. The getBoost
function is defined as follows:
function getBoost(address _account) public view returns (uint256) { return 100 * 1e18 + esLBRBoost.getUserBoost(_account, userUpdatedAt[_account], finishAt); }
It references the boost that the user has set in the esLBRBoost contract. As expected, in that contract, the longer the unlockTime
is for a particular boost setting, the greater the mining boost. However, the issue is then that there is no functionality in this contract which actually enforces that unlock time.
Let's look at the withdraw
function, where we might expect this unlock time to be enforced:
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); }
Notice that in the function, and no where else in this contract, is the unlock time referenced. This means that there's no downside for a user to arbitrarily select the boost settings with the longest unlock time & highest reward boost. They will receive the greatest amount of rewards with no obligation to stake or take any other action for the unlock time period.
Manual review
Implement logic in the rewards contracts where the boost rewards are only provided when the user actually stakes for the duration of their boost settings.
Other
#0 - c4-pre-sort
2023-07-10T20:58:34Z
JeffCX marked the issue as primary issue
#1 - c4-pre-sort
2023-07-11T21:32:36Z
JeffCX marked the issue as duplicate of #838
#2 - c4-judge
2023-07-28T13:06:56Z
0xean marked the issue as duplicate of #773
#3 - c4-judge
2023-07-28T15:38:18Z
0xean marked the issue as satisfactory
π Selected for report: No12Samurai
145.801 USDC - $145.80
The getReward
function in the ProtocolRewardsPool contract is incorrectly handling the distribution of EUSD rewards. The logic mishandles the difference between the value of a share vs a single EUSD token, effectively pricing each share the same as an individual EUSD token. This is invalid, because each share's value will increase over time to be greater than the value of a single EUSD token. This function sends the users an amount of EUSD shares equal to the number of underlying EUSD tokens that the user should actually be receiving.
Since the logic of the getReward
function will loop through each of the rewards tokens individually (starting with EUSD) and send them to the user, the first users who call getReward
will receive these excess EUSD rewards. All other users will then unfairly receive less rewards.
Consider the getReward
function of the ProtocolRewardsPool contract:
function getReward() external updateReward(msg.sender) { uint reward = rewards[msg.sender]; if (reward > 0) { rewards[msg.sender] = 0; IEUSD EUSD = IEUSD(configurator.getEUSDAddress()); uint256 balance = EUSD.sharesOf(address(this)); uint256 eUSDShare = balance >= reward ? reward : reward - balance; EUSD.transferShares(msg.sender, eUSDShare); if(reward > eUSDShare) { ERC20 peUSD = ERC20(configurator.peUSD()); uint256 peUSDBalance = peUSD.balanceOf(address(this)); if(peUSDBalance >= reward - eUSDShare) { peUSD.transfer(msg.sender, reward - eUSDShare); emit ClaimReward(msg.sender, EUSD.getMintedEUSDByShares(eUSDShare), address(peUSD), reward - eUSDShare, block.timestamp); } else { ... } } ... } }
Here, the function check whether the number of EUSD shares is greater than the reward owed to the user. If so, then it will send that number of shares. If not, then it will then check whether there is enough peUSD to cover the remaining owed rewards. If there is enough peUSD, it will transfer this remaining amount to the user: peUSD.transfer(msg.sender, reward - eUSDShare);
. From this call, you can see that this function is effectively pricing each EUSD share the same as each peUSD token. This is fundamentally wrong, as each EUSD share is intended to increase in value as the number of rebases increases. Therefore, this function is overpaying the EUSD rewards, which will result in the early users who call getReward
to get excess rewards at the expense of later users.
Manual review
The getReward
and notifyRewardAmount
functions should not be using the number of EUSD shares, but rather the number of EUSD tokens.
Other
#0 - c4-pre-sort
2023-07-10T02:13:35Z
JeffCX marked the issue as primary issue
#1 - c4-sponsor
2023-07-18T06:33:46Z
LybraFinance marked the issue as sponsor confirmed
#2 - c4-judge
2023-07-26T12:45:14Z
0xean marked the issue as satisfactory
#3 - c4-judge
2023-07-28T20:36:42Z
0xean marked the issue as selected for report
#4 - c4-judge
2023-07-29T22:06:52Z
0xean marked the issue as duplicate of #604
#5 - c4-judge
2023-07-29T22:07:04Z
0xean marked the issue as not selected for report
π Selected for report: hl_
Also found by: 0xRobocop, Co0nan, CrypticShepherd, DedOhWale, Iurii3, Kenshin, Musaka, OMEN, RedOneN, SpicyMeatball, Toshii, Vagner, bytes032, cccz, gs8nrv, hl_, kenta, lanrebayode77, mahdikarimi, max10afternoon, peanuts, pep7siup
5.5262 USDC - $5.53
In the _repay
function of the LybraPeUSDVaultBase contract, users are able to double count the _amount
amount of their repayment for both their owed fees and their borrowed amount of PeUSD. This will directly result in the protocol losing funds, as users can effectively pay only 50% of what they actually owe.
The _repay
function is defined as follows:
function _repay(address _provider, address _onBehalfOf, uint256 _amount) internal virtual { try configurator.refreshMintReward(_onBehalfOf) {} catch {} _updateFee(_onBehalfOf); uint256 totalFee = feeStored[_onBehalfOf]; uint256 amount = borrowed[_onBehalfOf] + totalFee >= _amount ? _amount : borrowed[_onBehalfOf] + totalFee; if(amount >= totalFee) { feeStored[_onBehalfOf] = 0; PeUSD.transferFrom(_provider, address(configurator), totalFee); PeUSD.burn(_provider, amount - totalFee); } else { feeStored[_onBehalfOf] = totalFee - amount; PeUSD.transferFrom(_provider, address(configurator), amount); } try configurator.distributeRewards() {} catch {} borrowed[_onBehalfOf] -= amount; poolTotalPeUSDCirculation -= amount; emit Burn(_provider, _onBehalfOf, amount, block.timestamp); }
Let's consider the following example, where a user has 1_001 in fees (feeStored[_onBehalfOf] = 1_001
) and has borrowed 10_000 in tokens (borrowed[_onBehalfOf] = 10_000
). A malicious user calls the _repay
function with _amount
=1_000. This call will enter the else block with amount
=1_000. First, feeStored[_onBehalfOf]
will be reduced by 1_000 and now equal 1. Secondly, borrowed[_onBehalfOf]
will then be reduced by 1_000 to equal 19_000. The user has effectively double counted their repayment _amount
, leading to a loss for the protocol.
Manual review
Update the implementation such that the function first calculates the excess amount above the owed fees by the user and stores this in a variable called excessFees
. Then call PeUSD.burn(..)
using this excessFees
amount. Then reduce borrowed[_onBehalfOf]
by this amount.
Other
#0 - c4-pre-sort
2023-07-10T01:43:12Z
JeffCX marked the issue as duplicate of #532
#1 - c4-judge
2023-07-28T15:39:28Z
0xean marked the issue as satisfactory
#2 - c4-judge
2023-07-28T19:41:44Z
0xean changed the severity to 2 (Med Risk)
π Selected for report: bytes032
Also found by: 0xMAKEOUTHILL, 0xgrbr, 0xkazim, 0xnacho, Arz, Co0nan, CrypticShepherd, Cryptor, HE1M, Iurii3, LaScaloneta, LokiThe5th, LuchoLeonel1, MrPotatoMagic, Musaka, Qeew, RedTiger, SovaSlava, Toshii, Vagner, a3yip6, azhar, bart1e, devival, hl_, jnrlouis, kutugu, peanuts, pep7siup, qpzm, smaul
1.3247 USDC - $1.32
All of the core functionality of the LybraRETHVault contract requires that the getAssetPrice
function does not fail. However, due to an incorrect function call (calling getExchangeRatio
rather than getExchangeRate
), this function always reverting. This means that the current implementation will not work at all.
The getAssetPrice
function of the LybraRETHVault contract is defined as follows:
function getAssetPrice() public override returns (uint256) { return (_etherPrice() * IRETH(address(collateralAsset)).getExchangeRatio()) / 1e18; }
The collateral in this case is rETH, with the referenced address being 0xae78736Cd615f374D3085123A210448E74Fc6393
. The issue is that the getExchangeRatio
function is not defined for this rETH contract, which will mean that all calls to the getAssetPrice
function will revert.
Manual review
Replace the call to getExchangeRatio
to getExchangeRate
.
Other
#0 - c4-pre-sort
2023-07-04T13:18:47Z
JeffCX marked the issue as duplicate of #27
#1 - c4-judge
2023-07-28T17:14:12Z
0xean changed the severity to 2 (Med Risk)
#2 - c4-judge
2023-07-28T17:15:09Z
0xean marked the issue as satisfactory
π Selected for report: 0xnev
Also found by: 0xRobocop, 0xbrett8571, 0xkazim, 0xnacho, 3agle, 8olidity, ABAIKUNANBAEV, Bauchibred, Co0nan, CrypticShepherd, D_Auditor, DelerRH, HE1M, Iurii3, Kaysoft, MrPotatoMagic, RedOneN, RedTiger, Rolezn, SanketKogekar, Sathish9098, Timenov, Toshii, Vagner, bart1e, bytes032, codetilda, devival, halden, hals, kutugu, m_Rassska, naman1778, nonseodion, seth_lawson, solsaver, squeaky_cactus, totomanov, y51r, yudan, zaevlad
9.931 USDC - $9.93
In the EUSDMiningIncentives contract, the address of WETH is hardcoded to the address on Ethereum. There is also no functionality to update the WETH address to work on different chains. This means that the EUSDMiningIncentives contract will not work on any chain other than Ethereum.
There is the following line of code referencing the WETH contract address in the stakedLBRLpValue
function:
uint256 etherInLp = (IEUSD(0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2).balanceOf(ethlbrLpToken) * uint(etherPrice)) / 1e8;
The issue is that this address only works on Ethereum, while the protocol is intended to be deployed on multiple chains. As the stakedLBRLpValue
function is used in all core rewards functions: getReward
and purchaseOtherEarnings
, this contract will essentially not work on any other chain.
Manual review
Rather than using a hardcoded address, assign the WETH address to a state variable, and add a function which can set that variable.
ERC20
#0 - c4-pre-sort
2023-07-08T13:25:36Z
JeffCX marked the issue as primary issue
#1 - c4-sponsor
2023-07-18T06:38:54Z
LybraFinance marked the issue as disagree with severity
#2 - 0xean
2023-07-26T12:47:31Z
@LybraFinance - can you explain why you disagree with the severity here? Is the protocol intended for multiple chains or only will ever be deployed to ETH mainnet?
#3 - LybraFinance
2023-07-27T08:23:13Z
Lybra protocol mainly operates on the Ethereum mainnet.
#4 - c4-judge
2023-07-27T16:59:36Z
0xean changed the severity to QA (Quality Assurance)
#5 - c4-sponsor
2023-07-29T08:49:53Z
LybraFinance marked the issue as sponsor confirmed
#6 - c4-sponsor
2023-07-29T08:49:58Z
LybraFinance marked the issue as sponsor acknowledged