Platform: Code4rena
Start Date: 16/02/2023
Pot Size: $144,750 USDC
Total HM: 17
Participants: 154
Period: 19 days
Judge: Trust
Total Solo HM: 5
Id: 216
League: ETH
Rank: 32/154
Findings: 1
Award: $455.47
🌟 Selected for report: 0
🚀 Solo Findings: 0
🌟 Selected for report: GalloDaSballo
Also found by: 0x3b, 0xAgro, 0xSmartContract, 0xTheC0der, 0xackermann, 0xnev, 0xsomeone, ABA, BRONZEDISC, Bjorn_bug, Bnke0x0, Breeje, Co0nan, CodeFoxInc, CodingNameKiki, DadeKuma, DeFiHackLabs, IceBear, Josiah, Kaysoft, Lavishq, MohammedRizwan, PaludoX0, PawelK, Phantasmagoria, Raiders, RaymondFam, Rickard, Rolezn, Sathish9098, SleepingBugs, SuperRayss, UdarTeam, Udsen, Viktor_Cortess, arialblack14, ast3ros, bin2chen, brgltd, btk, catellatech, ch0bu, chaduke, chrisdior4, codeislight, cryptonue, delfin454000, descharre, dontonka, emmac002, fs0c, hacker-dom, hansfriese, imare, lukris02, luxartvinsec, martin, matrix_0wl, peanuts, rbserver, shark, tnevler, trustindistrust, tsvetanovv, vagrant, yongskiws, zzzitron
455.4712 USDC - $455.47
tvlCap
can be less than current value lockedAdd a check to ensure new tvlCap
is not less than current value locked.
asserts()
From solidity docs: Properly functioning code should never reach a failing assert statement; if this happens there is a bug in your contract which you should fix.
With assert the user pays the gas and with require it doesn't. The ETH network gas isn't cheap. You have reachable asserts in the following locations (which should be replaced by require
/ are mistakenly left from development phase):
The Solidity assert()
function is meant to assert invariants. Properly functioning code should never reach a failing assert statement. A reachable assertion can mean one of two things:
A bug exists in the contract that allows it to enter an invalid state; The assert statement is used incorrectly, e.g. to validate inputs.
SWC ID: 110
Substitute asserts
with require
/revert
.
__gap[50]
storage variable to allow for new storage variables in later versionsFor upgradeable contracts, there must be storage gap to "allow developers to freely add new state variables in the future without compromising the storage compatibility with existing deployments" (quote OpenZeppelin). Otherwise it may be very difficult to write new implementation code. Without storage gap, the variable in child contract might be overwritten by the upgraded base contract if new variables are added to the base contract. This could have unintended and very serious consequences to the child contracts, potentially causing loss of user fund or cause the contract to malfunction completely. See.
For a description of this storage variable. While some contracts may not currently be sub-classed, adding the variable now protects against forgetting to add it in the future.
In the following context of the upgradeable contracts they are expected to use gaps for avoiding collision:
UUPS
However, none of these contracts contain storage gap. The storage gap is essential for upgradeable contract because "It allows us to freely add new state variables in the future without compromising the storage compatibility with existing deployments". Refer to the bottom part of this article.
If a contract inheriting from a base contract contains additional variable, then the base contract cannot be upgraded to include any additional variable, because it would overwrite the variable declared in its child contract. This greatly limits contract upgradeability.
UUPSUpgradeable, AccessControlEnumerableUpgradeable
Manual analysis
Recommend adding appropriate storage gap at the end of upgradeable contracts such as the below.
uint256[50] private __gap;
Reference OpenZeppelin upgradeable contract templates.
Add event emissions for important variables changes and actions.
The critical procedures should be two step process.
Lack of two-step procedure for critical operations leaves them error-prone. Consider adding two step procedure on the critical functions and / or a timelock
updateCollateralRatios
is not safe without a two-step procedureAs notice at: " !!!PLEASE USE EXTREME CARE AND CAUTION. THIS IS IRREVERSIBLE!!! // // You probably don't want to do this unless a specific asset has proved itself through tough times. // Doing this irresponsibly can permanently harm the protocol."
This functions is critical. Therefore, using a two steps procedure would be optimal.
Consider adding a time before this change is completely done so it can be canceled, or do it in a 2 steps manner so it can be reviewed before setting it up.
_safeTransfer()
should be use rather than _transfer
distributionPeriod
can be changed without anybody noticing and removing rewardPerSecond
until changed backupdateDistributionPeriod
doesn't use any kind of event, so if by error the owner sets the value to a high one, rewards per seconds at fund
will not be distributed because of rounding down at rewardPerSecond = amount.div(distributionPeriod);
So this human fail can lead to an a not noticed change, due to missing the event, that will make rewards to be 0 until notice.
function fund(uint amount) external onlyOwner { require(amount != 0, "cannot fund 0"); OathToken.transferFrom(msg.sender, address(this), amount); // roll any unissued OATH into new distribution if (lastIssuanceTimestamp < lastDistributionTime) { uint timeLeft = lastDistributionTime.sub(lastIssuanceTimestamp); uint notIssued = timeLeft.mul(rewardPerSecond); amount = amount.add(notIssued); } rewardPerSecond = amount.div(distributionPeriod); //@audit div by high value lastDistributionTime = block.timestamp.add(distributionPeriod); lastIssuanceTimestamp = block.timestamp; emit LogRewardPerSecond(rewardPerSecond); } // Owner-only function to update the distribution period function updateDistributionPeriod(uint256 _newDistributionPeriod) external onlyOwner { distributionPeriod = _newDistributionPeriod; }
_approve
can be frontrunnedConsider to require to set allowance to 0 first as suggested at,
emit
keywordGiven that some contracts are derived from Ownable
, the ownership management of this contract defaults to Ownable
’s transferOwnership()
and renounceOwnership()
methods which are not overridden here.
Such critical address transfer/renouncing in one-step is very risky because it is irrecoverable from any mistakes
Scenario: If an incorrect address, e.g. for which the private key is not known, is used accidentally then it prevents the use of all the onlyOwner()
functions forever, which includes the changing of various critical addresses and parameters. This use of incorrect address may not even be immediately apparent given that these functions are probably not used immediately.
When noticed, due to a failing onlyOwner()
function call, it will force the redeployment of these contracts and require appropriate changes and notifications for switching from the old to new address. This will diminish trust in the protocol and incur a significant reputational damage.
Ownable
CollateralConfig.sol#L15 contract CollateralConfig is ICollateralConfig, CheckContract, Ownable {
BorrowerOperations.sol#L18 contract BorrowerOperations is LiquityBase, Ownable, CheckContract, IBorrowerOperations {
TroveManager.sol#L18 contract TroveManager is LiquityBase, /Ownable,/ CheckContract, ITroveManager {
ActivePool.sol#L26 contract ActivePool is Ownable, CheckContract, IActivePool {
StabilityPool.sol#L146 contract StabilityPool is LiquityBase, Ownable, CheckContract, IStabilityPool {
CommunityIssuance.sol#L14 contract CommunityIssuance is ICommunityIssuance, Ownable, CheckContract, BaseMath {
LQTYStaking.sol#L18 contract LQTYStaking is ILQTYStaking, Ownable, CheckContract, BaseMath {
Ownable2Step
from OpenZeppelin or Owned
from SolmateUnderscore is a valid symbol used for clarity while reading numbers, this symbol is used in general for separating hundreds (i.e: 4_000
) however, it is being used in a not typical position 40_00
.
ActivePool.sol#L127 require(_bps <= 10_000, "Invalid BPS value");
ActivePool.sol#L133 require(_driftBps <= 500, "Exceeds max allowed value of 500 BPS");
ActivePool.sol#L145 require(_treasurySplit + _SPSplit + _stakingSplit == 10_000, "Splits must add up to 10000 BPS");
ActivePool.sol#L258 vars.percentOfFinalBal = vars.finalBalance == 0 ? uint256(-1) : vars.currentAllocated.mul(10_000).div(vars.finalBalance);
ActivePool.sol#L262 vars.finalYieldingAmount = vars.finalBalance.mul(vars.yieldingPercentage).div(10_000);
ActivePool.sol#L291 vars.treasurySplit = vars.profit.mul(yieldSplitTreasury).div(10_000);
ActivePool.sol#L296 vars.stakingSplit = vars.profit.mul(yieldSplitStaking).div(10_000);
StabilityPool.sol#L768 if (compoundedDeposit < initialDeposit.div(1e9)) {return 0;}
ActivePool.sol#L127 require(_bps <= 10_000, "Invalid BPS value");
ActivePool.sol#L145 require(_treasurySplit + _SPSplit + _stakingSplit == 10_000, "Splits must add up to 10000 BPS");
1e18
) rather than exponentiation (e.g. 10 ** 18
)Consider replacing 10 ** value
notation to scientific notation
type(uint).max
and uint(-1)
affects consistencyAvoid using mixed ways of accessing max value as this affects consistency.
Some lines use // x
and some use //x
. Following the common style comment should be as follows:
-uint256 public genericDeclaration; //generic comment without space` +uint256 public genericDeclaration; // generic comment with space`
Non assembly methods are available for this values
assembly{ id := chainid() }
=> uint256 id = block.chainid
,
LUSDToken.sol#L300 chainID := chainid()
Formula uses a hardcoded value of 365
(days) which would be wrong applied in a leap year (366
days).
365.2422 days
for maximum precision(NASA value)NOTE
: None of these findings where found by 4naly3er output - NC
Index event fields make the field more quickly accessible to off-chain tools that parse events. However, note that each index field costs extra gas during emission, so it's not necessarily best to index the maximum allowed per event (three fields). Each event should use three indexed fields if there are three or more fields, and gas usage is not particularly of concern for the events in question. If there are fewer than three fields, all of the fields should be indexed.
Consider which event parameters could be particularly useful to off-chain tools and should be indexed.
Some of the contracts include an unlocked pragma, e.g., pragma solidity >=0.8.0
.
Locking the pragma helps ensure that contracts are not accidentally deployed using an old compiler version with unfixed bugs.
Lock pragmas to a specific Solidity version.
uint
and uint256
for the same type affects consistencyAll over the code uint
and uint256
, what are the same type, are used with no consistency even in the same contract.
Consider using only uint
or uint256
for clarity and consistency.
NOTE
: None of these findings where found by 4naly3er output - NC
require
/revert
statements should include error messages in order to help at monitoring the system.
mapping(
/ mapping (
, for (
/ for(
are used without consistency, affecting searchabilityAll over the code there is no consistency when writting spacings and (
. Not only as mentioned in the title, but ( variable
and (variable
are used, etc. This really affects how easy is to maintain the code as it is hard to search at it.
Better to follow Solidity guidelines as it would be following for(variable
rather than for ( variable
or other mixes of style.
As example:
LQTYStaking.sol#L25
mapping( address => uint) public stakes;
StabilityPool.sol#L223
mapping (uint128 => mapping(uint128 => uint)) public epochToScaleToG;
now
rather than block.timestamp
ecrecover()
The function calls the Solidity ecrecover()
function directly to verify the given signatures. However, the ecrecover()
EVM opcode allows malleable (non-unique) signatures and thus is susceptible to replay attacks.
Although a replay attack seems not possible for this contract, I recommend using the battle-tested OpenZeppelin’s ECDSA library.
Use the ecrecover
function from OpenZeppelin’s ECDSA library for signature verification. (Ensure using a version > 4.7.3 for there was a critical bug >= 4.1.0 < 4.7.3).
Add a not same value check like this:
if (_newCoolAddress == newCoolAddress) revert ADDRESS_SAME();
_renounceOwnership
can lead to unexpected behaviorsRenouncing to the ownership of a contract can lead to unexpected behaviors. Method setAddresses
can only be called once therefore, due to the call from the method to _renounceOwnership
. Consider using a method to submit a proposal of new addresses, then another one to accept the proposal where _renounceOwnership
is called at the end.
BorrowerOperations.sol#L167 _renounceOwnership();
StabilityPool.sol#L302 _renounceOwnership();
LQTYStaking.sol#L100 _renounceOwnership();
TroveManager.sol#L294 owner = address(0);
// import "./Dependencies/Ownable.sol";
strategy is not validated (could not exist) and this function would return 0 on that case.
Usually lines in source code are limited to 80 characters. Today’s screens are much larger so it’s reasonable to stretch this in some cases. Solidity newer guidelines suggest 120 characters. Reference: Long lines should be wrapped to conform with Solidity Style guidelines
Following lines with more than 120:
#0 - c4-judge
2023-03-08T12:25:33Z
trust1995 marked the issue as grade-a
#1 - c4-sponsor
2023-03-16T18:19:57Z
0xBebis marked the issue as sponsor confirmed