Platform: Code4rena
Start Date: 21/12/2023
Pot Size: $90,500 USDC
Total HM: 10
Participants: 39
Period: 18 days
Judge: LSDan
Total Solo HM: 5
Id: 315
League: ETH
Rank: 14/39
Findings: 2
Award: $385.84
🌟 Selected for report: 0
🚀 Solo Findings: 0
🌟 Selected for report: IllIllI
Also found by: 0x11singh99, 0xA5DF, 0xMilenov, 0xTheC0der, 7ashraf, Bauchibred, EV_om, Kaysoft, Sathish9098, SpicyMeatball, cheatc0d3, erebus, hash, imare, immeas, joaovwfreire, lil_eth, lsaudit, oakcobalt, para8956, peanuts, rvierdiiev, slvDev, trachev
21.8971 USDC - $21.90
MIN_VESTING is currently set as 1 days in Depository.sol.
uint256 public constant MIN_VESTING = 1 days;
This value cannot be changed, but ideally it should be flexible because it will give the protocol more opportunities to grow their system. For example, setting min vesting to less than a day can incentivise more people to bond their LP tokens for OLAS tokens. Changing the amount will incur more centralization risks, but since the protocol is heavily reliant on centralization already, it will be better to add more convenience for a slight increase in centralization risk.
getBonds()
in Depository.sol may face out of gas error since bondCounter only increases for every bond created through deposit()
In getBonds()
, the function attempts to get all the bonds from one account using a loop.
uint256 numBonds = bondCounter; bool[] memory positions = new bool[](numBonds); // Record the bond number if it belongs to the account address and was not yet redeemed for (uint256 i = 0; i < numBonds; ++i) {
bondCounter
increases everytime a user calls deposit()
and will never decrease
// Create and add a new bond, update the bond counter bondId = bondCounter; mapUserBonds[bondId] = Bond(msg.sender, uint96(payout), uint32(maturity), uint32(productId)); bondCounter = uint32(bondId + 1);
This means that as more users start to call deposit()
to create a bond, the value of bondCounter
will increase. If it gets too large, the getBonds()
function will run out of gas as it attempts to loop through every bond created just to find the bond that a particular account has created.
To give specific numbers, if Alice creates the first bond, bondCounter = 1
, and there are 10000 bonds created after that, and Alice creates another bond bondCounter = 10001
, then the function has to loop 10001 times just to find that Alice has created the first bond and the latest bond.
This function is extremely gas intensive. A better way is to create a mapping for the particular user every time a bond is created through deposit()
, and push the new value into the mapping.
deposit()
function in Depository.sol can be DoS'd by ensuring that payout > supply everytimeIn deposit()
, a user deposits a certain amount of LP tokens to get a certain amount of OLAS tokens. This calculation is done through IGenericBondCalculator(bondCalculator).calculatePayoutOLAS
, and if the payout is greater than the supply, revert the whole function
// Check for the sufficient supply if (payout > supply) { revert ProductSupplyLow(token, productId, payout, supply); }
To calculate the exact amount such that payout == supply, the user has to do his own calculation and call calculatePayoutOLAS()
directly.
function calculatePayoutOLAS(uint256 tokenAmount, uint256 priceLP) external view returns (uint256 amountOLAS) {
Let's say that the remaining supply of OLAS token is 10000, and it takes 100 LP tokens to get 1000 OLAS tokens. Alice calls deposit()
and deposit 100 LP tokens, with the intention of getting the last 1000 OLAS tokens. Another user sees this, and frontruns the transaction by depositing 1 wei of LP tokens and get 10 wei of OLAS tokens. Alice transaction then gets executed, but reverts because the payout is now greater than the supply. Alice has to calculate again and deposit 100-1wei of LP tokens to get 1000-10wei of OLAS tokens.
Instead of reverting when the payout > supply, the function should recalculate the payout so that payout == supply
if (payout > supply) { // Have another calculation in here such that payout == supply // This way, users who want to deposit a large amount of LP tokens to get the remaining OLAS token supply will not get DoS'd easily // Another way is to have a minimum LP deposit, so users cannot DoS with 1 wei, but this will incur dust tokens // Dust tokens isn't an issue as the bond can be closed with the remaining supply returning back to the effectiveBond in Tokenomics contract }
payout
can still be non-zero when depositing LP tokens in Depository.solIn deposit()
, the comments state that payout cannot be zero since the price LP is non-zero
// Calculate the payout in OLAS tokens based on the LP pair with the discount factor (DF) calculation // Note that payout cannot be zero since the price LP is non-zero, otherwise the product would not be created payout = IGenericBondCalculator(bondCalculator).calculatePayoutOLAS(tokenAmount, product.priceLP);
This is the calculation:
uint256 totalTokenValue = mulDiv(priceLP, tokenAmount, 1); // Check for the cumulative LP tokens value limit if (totalTokenValue > type(uint192).max) { revert Overflow(totalTokenValue, type(uint192).max); } // Amount with the discount factor is IDF * priceLP * tokenAmount / 1e36 // At this point of time IDF is bound by the max of uint64, and totalTokenValue is no bigger than the max of uint192 amountOLAS = ITokenomics(tokenomics).getLastIDF() * totalTokenValue / 1e36;
If the price of the LP is below 1e18, and the tokenAmount is extremely small (in wei), the payout can be truncated to zero assuming that getLastIDF() returns 1e18.
totalTokenValue = 0.9e17 * 1 / 1 = 0.9e17
amountOLAS = 1e18 * 0.9e17 / 1e36 = 0
It is possible for the payout to return a non-zero number.
Check that payout is not zero before continuing the function.
In veCRV, there is a check when creating a lock,
self.assert_not_contract(msg.sender)
which means that smart contracts are not allowed to vote.
@internal def assert_not_contract(addr: address): """ @notice Check if the call is from a whitelisted smart contract, revert if not @param addr Address to be checked """ if addr != tx.origin: checker: address = self.smart_wallet_checker if checker != ZERO_ADDRESS: if SmartWalletChecker(checker).check(addr): return raise "Smart contract depositors not allowed"
There is no such check in veOLAS.sol.
One possible reason to prevent smart contracts from participating is to reduce the attack vectors such as flashloan attacks.
To improve consistency of fork code, check for the contract existence size in veOLAS.sol as well.
Users can call the createLockFor()
function to create a lock for another account.
function createLockFor(address account, uint256 amount, uint256 unlockTime) external { // Check if the account address is zero if (account == address(0)) { revert ZeroAddress(); } _createLockFor(account, amount, unlockTime); }
Griefers can create a lock for random users with 1 wei as the amount and 4 years as the unlock time to purposely grief users. Some genuine users of the OLAS protocol may not want to lock their tokens for 4 years, but a malicious griefer can frontrun their createLock function and create a lock for them with 4 years as the unlockTime
. Since the unlockTime cannot be reduced after creation, the genuine users will be affected and have to create another account just to create a lock.
In the original veCRV implementation, there is no function that allows a user to create a lock for other users.
@external @nonreentrant('lock') def create_lock(_value: uint256, _unlock_time: uint256): """ @notice Deposit `_value` tokens for `msg.sender` and lock until `_unlock_time` @param _value Amount to deposit @param _unlock_time Epoch time when tokens unlock, rounded down to whole weeks """
The only functionality in the veCRV implementation that allows user to help other users is the depositfor function.
It is best to remove the createLockFor function in veOLAS.sol to prevent any griefing capabilities. Just allow the msg.sender to create a lock for himself.
#0 - c4-pre-sort
2024-01-10T14:43:27Z
alex-ppg marked the issue as sufficient quality report
#1 - alex-ppg
2024-01-10T14:48:59Z
L-02 dupe of #270 L-03 may be dupe of another submission, I do not recall and couldn't find which L-04 dupe of #25 L-06 dupe of #451
#2 - c4-judge
2024-01-19T21:27:46Z
dmvt marked the issue as grade-b
🌟 Selected for report: hunter_w3b
Also found by: Sathish9098, joaovwfreire, pavankv, peanuts
363.9423 USDC - $363.94
OLAS protocol has four parts, bonding, registries and voting and bridging. Users will find themselves integrating with voting and bonding more than other mechanisms.
For voting, the mechanism is similar to veCRV voting.
For Bonding, users will deposit LP tokens (OLAS-X, where X can be another common token like ETH or DAI at the protocol's discretion) to get OLAS token. Owner will specify the bonding rubrics.
Contract | Function | Explanation | Comments |
---|---|---|---|
veOLAS | depositFor | Deposit amount of OLAS and adds amount to the current lock | Lock must already be created, another person can help to deposit |
veOLAS | createLockFor | Creates a lock, deposit amount of OLAs and sets unlockTime | Another person can help to create the lock |
veOLAS | increaseAmount | Deposit amount of OLAS and adds amount to the current lock | Similar to depositFor, but only for msg.sender |
veOLAS | increaseUnlockTime | Increase the current unlock time for the current lock | Only for msg.sender, cannot increase past MAXTIME |
veOLAS | withdraw | Withdraw all tokens for msg.sender | Must withdraw total balance, lock must expire |
veOLAS | _balanceOfLocked | Gets the voting power of an account at a particular time | view function, used only in getVotes |
User can create a lock to deposit OLAS. User also specifies the duration of the lock.
More OLAS deposited, more voting power. The longer the lock duration, the more voting power
Users can create a lock for others and also increase the amount deposited for others
Only the owners of the lock can increase the duration of the lock. Lock duration has a max limit
This contract is extremely similar to the veCRV contract, so I did a comparison between both contracts
veCRV does not allow contracts to create locks, but veOLAS does
veCRV uses block epoch, but veOLAS uses block.number
Recommend restricting contracts to have voting power, only addresses, to prevent any potential griefing/attacks
Recommend not to allow users to create lock for other users to prevent griefing attacks (locking 1 wei for 4 years for an account)
Contract | Function | Explanation | Comments |
---|---|---|---|
Treasury | receive | Must be above 0.065 ether (which can be changed by owner) | Owner controls minAcceptedETH, also function does not need to check for overflow |
Treasury | changeOwner | Changes the owner | onlyOwner, Checks for 0 address, has modifier, but no two-step address change |
Treasury | changeManagers | Changes the managers | onlyOwner, Checks for 0 address, has modifier, but no two-step address change |
Treasury | changeMinAcceptedETH | Changes minimum accepted ETH (currently 0.065) | onlyOwner, Check for zero value, but no upper limit |
Treasury | depositTokenForOLAS | Depository gets LP tokens and mints Olas | onlyDepository, requires account to give allowance |
Treasury | depositServiceDonationsETH | Contract gets service donations, transfer not in function | increases the ETHFromServices variable, uses reentrancy guard |
Treasury | withdraw | Withdraws ETH or LP token from contract to specified address | onlyOwner, updates ETHOwned / mapTokenReserves[token] |
Treasury | withdrawToAccount | Withdraws ETH from contract to specified address | onlyDispenser, checks pause, withdraws ETHFromServices, accTopUps can be 0 |
Treasury | rebalanceTreasury | Increases ETHOwned and decreases ETHFromServices | onlyTokenomics |
Treasury | drainServiceSlashedFunds | Slash funds from service registry | onlyOwner, function calls serviceRegistry contract |
Treasury | enableToken | Enable LP token address to be used by contract | onlyOwner, changes mapEnabledTokens[token] = true, useful in withdraw and deposit LP |
Treasury | disableToken | Disable LP token address to be used by contract | onlyOwner, checks whether reserve of token is 0, change mapEnabledTokens to false |
Treasury | pause | Pauses the contract | onlyOwner, used in rebalance and withdrawToAccount |
Treasury | unpause | Unpause the contract | onlyOwner, used in rebalance and withdrawToAccount |
There are two types of ETH that is tracked, ETHOwned and ETHFromServices
ETHOwned is withdrawn through withdraw()
and ETHFromServices is withdrawn through withdrawToAccount()
There is mapEnabledTokens[token] and mapTokenReserves[token]. Both points to the LP token. One of them is a boolean, one is the amount of reserves
mapTokenReserves[token] is increased through depositTokenForOLAS()
and decreased through withdraw()
OLAS is minted through depositTokenForOLAS()
and potentially withdrawToAccount()
Recommend creating a modifier for msg.sender == owner since it is repeated many times in the code
changeOwner()
should have a two-step transfer
changeManagers()
should have a two-step transfer
changeMinAcceptedETH()
should have a upper limit (eg max minEth accepted is 0.5 ETH)
pause and unpause mechanism only used for 2 functions, consider using for more functions
Contract | Function | Explanation | Comments |
---|---|---|---|
Depository | changeOwner | Change the owner | onlyOwner, Checks for 0 address, has modifier, but no two-step address change |
Depository | changeManagers | Changes the managers | onlyOwner, Checks for 0 address, has modifier, but no two-step address change |
Depository | changeBondCalculator | Changes the bond calculator contract | onlyOwner, Checks for 0 address, has modifier, but no two-step address change |
Depository | create | Create a bond | onlyOwner, increases productCounter variable and saves mapBondProducts |
Depository | close | Close a bond, uses productId | onlyOwner, increases numClosedProducts, saves closedProductIds, delete mapBondProducts |
Depository | deposit | User calls this function to deposit LP tokens | calls calculatePayoutOLAS, updates supply, creates mapUserBonds |
Depository | redeem | User calls this function to withdraw OLAS tokens | withdraws OLAS after maturity (which is block.timestamp + vesting time) |
Depository | getProducts | Get active / inactive products | Checks how many LP tokens are created and active |
Depository | isActiveProduct | Check if a productId is active | Check whether supply of the productId > 0 |
Depository | getBonds | Get bond info for an account | Can check only for matured bonds, or for both matured and not yet ready |
Depository | getBondStatus | Get maturity and payout info for a bondId | Payout can be zero, bond has been redeemed or never existed |
Depository | getCurrentPriceLP | Get the current price of the LP | Called from BondCalculator contract |
MIN_VESTING is one day, cannot be changed
productId refers to the bond, and the productId is monotonically increasing
productCounter will never decrease, only increase
create()
refers to how much OLAS can be supplied for a given LP amount
every time create()
is called, a new Product struct is created and saved in the mapBondProducts setting
For the Product struct, PriceLP refers to the price of the LP, vesting refers to the number of seconds (not end time), token refers to the LP token address, supply refers to the OLAs token amount
In deposit(), payout is calculated with amount and priceLP using the calculatePayoutOLAS function, supply is decreased, mapUserBonds is created, LP token is deposited, Olas token is minted
In deposit(), mapBondProducts is auto deleted if supply reaches 0.
getBonds()
, if set to true, then will get only matured bonds from an account
getBonds()
, if set to false, then will get all bonds from an account.
getBonds()
loops all bonds, may cause out of gas error
Recommend making MIN_VESTING flexible, changed with owner's discretion
Checking whether msg.sender == owner should be a modifier since it is repeated many times in the code
productCounter only increasing will affect getProducts(), might reach out of gas error
bondCounter only increasing will affect getBonds(), might reach out of gas error
payout may truncate to zero with extremely small tokenAmount
Contract | Function | Explanation | Comments |
---|---|---|---|
TokenomicsConstant | getSupplyCapForYear | Get supply cap for year | Checked using Remix, numYears == 1000 still works, pure function |
TokenomicsConstant | getInflationForYear | Get inflation for year | Checked using Remix, numYears == 1000 still works, pure function |
DonatorBlacklist | changeOwner | Change the owner | onlyOwner, Checks for 0 address, has modifier, but no two-step address change |
DonatorBlacklist | setDonatorsStatuses | Set blacklist status | onlyOwner, used only in Tokenomics.trackServiceDonations() |
DonatorBlacklist | isDonatorBlacklisted | Check if donator is blacklisted | onlyOwner, used only in Tokenomics.trackServiceDonations() |
Dispenser | changeOwner | Change the owner | onlyOwner, Checks for 0 address, has modifier, but no two-step address change |
Dispenser | changeManagers | Changes the managers | onlyOwner, Checks for 0 address, has modifier, but no two-step address change |
Dispenser | claimOwnerIncentives | Claims incentives for owner | calls accountOwnerIncentives, withdrawToAccount, affects only msg.sender |
Contract | Role | Risk | Explanation |
---|---|---|---|
Depository | Owner | High | Controls bonding of LP tokens and supply, changeOwner and changeManagers |
Treasury | Owner | High | Controls draining of services, LP token for bonding, minAcceptedETH, changeOwner and changeManagers |
DonatorBlacklist | Owner | High | Controls donators statuses, changeOwner |
Tokenomics | Owner | High | Controls changeTokenomicsParameters, changeOnwer, changeManagers, changeRegistries |
The owner controls most of the flow of the protocol.
Owner can set all addresses, have capabilities like changeOwner and changeManager
Owner also controls the amount of supply for a given LP bond, and controls the tokenomicsParameters
Owner cannot withdraw funds from the user, which is a good thing, but since the owner controls the protocol, the protocol still have a high centralization risk
Centralization Risk - High
Design Patterns and Best Practices:
There is good usage of established design patterns, like pausing mechanism and reentrancy guard. There is also consistent checks for zero values and overflows. This shows that the protocol is well versed in security.
One good example is the usage of allowance check in Treasury.sol. Even though the allowance check is not needed because transferFrom inherently check for allowance, the function still checks because Uniswap allowance implementation does not revert with the accurate message. This shows that the protocol understands how external functionality works well.
Code Readability and Maintainability:
Functions are pretty difficult to understand, but the NATSPECs does help in facilitating understanding. Code can sometimes be repetitive, such as the repeated checking of owner. Some overflow checks are also redundant because of 0.8.0 where overflows will automatically be reverted.
Error Handling and Input Validation:
Events and Error messages are easy to understand. Error messages are also written neatly at the top of the contract, with sufficient NATSPEC explaining the reasoning for the error.
Interoperability and Standards Compliance:
Good knowledge of the ERC20 standard when creating the OLAS tokens. This is seen from protocol checking for success value after transferFrom, and creating a soft max cap with 2% inflation rate for their token.
Testing and Documentation:
Extensive tests (unit, integration tests) done, reaching an overall coverage of almost 100%. Documentation (whitepaper, github) is plentiful and includes quality reasoning at every juncture of the protocol. One improvement could be having more diagrams and a summarized version of how the protocol works from the top down, with a end-to-end scenario of how a user can interact with the protocol (what function should the user call first, what can a user accomplish, why is the user incentivized to use the protocol)
Upgradeability:
Most of the protocol is not intended to be upgradeable, but contracts can be redeployed and rerouted easily through the changeManagers and changeRegistries function.
Dependency Management:
Protocol relies on external libraries like OpenZeppelin, and also uses code from other contracts (veCRV, FXPortal, Arbitrary Message Bridge). If those code are not well secured, it will affect the protocol. Protocol should keep an eye on vulnerabilities that affects those external integrations, and make changes where necessary.
Overall, great architecture from the protocol, slight changes would be to the written code itself (using modifiers for repeated code, checking zero values, checking overflows etc) and more explicit documentation
OLAS.sol
, veOLAS.sol
, wveOLAS.sol
, GovernorOLAS.sol
, Depository.sol
, Dispenser.sol
, DonatorBlacklist.sol
, GenericBondCalculator.sol
, Tokenomics.sol
, TokenomicsConstants.sol
, TokenomicsProxy.sol
, Treasury.sol
. For the rest of the contracts, I have looked only looked briefly just to contextualize the whole protocol.025 hours
#0 - c4-pre-sort
2024-01-10T12:28:02Z
alex-ppg marked the issue as sufficient quality report
#1 - c4-judge
2024-01-19T21:28:31Z
dmvt marked the issue as grade-b