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: 54/154
Findings: 2
Award: $103.33
🌟 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
61.2601 USDC - $61.26
_requireValidMaxFeePercentage()
In _requireValidMaxFeePercentage()
of BorrowerOperations.sol, the upper bound of _maxFeePercentage
is DECIMAL_PRECISION
, i.e. 1e18 (or 100%). However, when getBorrowingFee()
is eventually invoked by _triggerBorrowingFee()
, the output returned will at most be equal to MAX_BORROWING_FEE
, which is 5%. As such, _requireValidMaxFeePercentage()
should be refactored as follows, regardless of the minimal impact this has on _requireUserAcceptsFee()
, to be more expediently in line with the logic flow entailed:
File: BorrowerOperations.sol#L648-L656
function _requireValidMaxFeePercentage(uint _maxFeePercentage, bool _isRecoveryMode) internal pure { if (_isRecoveryMode) { - require(_maxFeePercentage <= DECIMAL_PRECISION, "Max fee percentage must less than or equal to 100%"); + require(_maxFeePercentage <= 5e15, "Max fee percentage must less than or equal to 5%"); } else { - require(_maxFeePercentage >= BORROWING_FEE_FLOOR && _maxFeePercentage <= DECIMAL_PRECISION, "Max fee percentage must be between 0.5% and 100%"); + require(_maxFeePercentage >= BORROWING_FEE_FLOOR && _maxFeePercentage <= 5e15, "Max fee percentage must be between 0.5% and 5%"); } }
baseRate
till the first call on redeemCollateral()
The impact, though low, is twofold. First off, the output of getBorrowingFee()
is always going to be BORROWING_FEE_FLOOR
, i.e. 0.5% because _baseRate == 0
:
File: TroveManager.sol#L1464-L1469
function _calcBorrowingRate(uint _baseRate) internal pure returns (uint) { return LiquityMath._min( BORROWING_FEE_FLOOR.add(_baseRate), MAX_BORROWING_FEE ); }
Next, when baseRate
finally gets assigned via updateBaseRateFromRedemption()
, increasing the baseRate
based on the amount redeemed, as a proportion of total supply, is going to be minimally negligible unless the redemption amount is significantly sizable. And, if subsequently LUSD borrowing operations exceed redemptions, baseRate
is as good as zero comparatively in [5e15, 5e16].
In another words, this makes the complexity of introducing a decaying factor pegged to a half-life of 720 minutes pretty much obsolete considering the borrowing rate stays always at 0.5%.
Consider having _calcDecayedBaseRate()
refactored as follows if it is going to take a while before the first redemption is made. Better yet, return 0
if baseRate
falls below a reasonable dust value:
File: TroveManager.sol#L1509-L1514
function _calcDecayedBaseRate() internal view returns (uint) { + if (baseRate == 0) return 0; uint minutesPassed = _minutesPassedSinceLastFeeOp(); uint decayFactor = LiquityMath._decPow(MINUTE_DECAY_FACTOR, minutesPassed); return baseRate.mul(decayFactor).div(DECIMAL_PRECISION); }
if else
checksIn BorrowerOperations.sol, the else
clauses of _moveTokensAndCollateralfromAdjustment()
are allowing _repayLUSD()
and _activePool.sendCollateral()
to proceed even if the respective function parameters, i.e. _LUSDChange
and _collChange
are 0
. Specifically, addColl()
, moveCollateralGainToTrove()
, and withdrawColl()
will have _LUSDChange
inputted as 0
whereas withdrawLUSD()
and repayLUSD()
will have zero _collChange
associated. Consider having the affected function refactored as follows to stem futile executions entailing zero repayment and collateral sending:
File: BorrowerOperations.sol#L476-L502
function _moveTokensAndCollateralfromAdjustment ( IActivePool _activePool, ILUSDToken _lusdToken, address _borrower, address _collateral, uint _collChange, bool _isCollIncrease, uint _LUSDChange, bool _isDebtIncrease, uint _netDebtChange ) internal { if (_isDebtIncrease) { _withdrawLUSD(_activePool, _lusdToken, _collateral, _borrower, _LUSDChange, _netDebtChange); } else { - _repayLUSD(_activePool, _lusdToken, _collateral, _borrower, _LUSDChange); + if (_LUSDChange != 0) _repayLUSD(_activePool, _lusdToken, _collateral, _borrower, _LUSDChange); } if (_isCollIncrease) { IERC20(_collateral).safeTransferFrom(msg.sender, address(this), _collChange); _activePoolAddColl(_activePool, _collateral, _collChange); } else { - _activePool.sendCollateral(_collateral, _borrower, _collChange); + if (_collChange != 0) _activePool.sendCollateral(_collateral, _borrower, _collChange); } }
The protocol adopts version 0.6.11 on writing some of the smart contracts. For better security, it is best practice to use the latest Solidity version, 0.8.17.
Security fix list where the protocol could benefit from, e.g. SafeMath in the versions can be found in the link below:
https://github.com/ethereum/solidity/blob/develop/Changelog.md
Here are the contract instances entailed:
File: CollateralConfig.sol#L3 File: BorrowerOperations.sol#L3 File: TroveManager.sol#L3 File: ActivePool.sol#L3 File: StabilityPool.sol#L3 File: CommunityIssuance.sol#L3 File: LQTYStaking.sol#L3 File: LUSDToken.sol#L3
For cleaner Solidity code in conjunction with the rule of modularity and modular programming, use named imports with curly braces instead of adopting the global import approach.
For instance, the import instances below could be refactored conforming to most other instances in the code bases as follows:
File: CollateralConfig.sol#L5-L8
- import "./Dependencies/CheckContract.sol"; + import {CheckContract} from "./Dependencies/CheckContract.sol"; - import "./Dependencies/Ownable.sol"; + import {Ownable} from "./Dependencies/Ownable.sol"; - import "./Dependencies/SafeERC20.sol"; + import {SafeERC20} from "./Dependencies/SafeERC20.sol"; - import "./Interfaces/ICollateralConfig.sol"; + import {ICollateralConfig} from "./Interfaces/ICollateralConfig.sol";
Solidity contracts can use a special form of comments, i.e., the Ethereum Natural Language Specification Format (NatSpec) to provide rich documentation for functions, return variables and more. Please visit the following link for further details:
https://docs.soliditylang.org/en/v0.8.16/natspec-format.html
Consider equipping all contracts with complete set of NatSpec to better facilitate users/developers interacting with the protocol's smart contracts.
Functions with named returns should not have a return statement to avoid unnecessary confusion.
For instance, the following _getTCR()
may be refactored as follows:
function _getTCR(address _collateral, uint _price, uint256 _collateralDecimals) internal view returns (uint TCR) { uint entireSystemColl = getEntireSystemColl(_collateral); uint entireSystemDebt = getEntireSystemDebt(_collateral); TCR = LiquityMath._computeCR(entireSystemColl, entireSystemDebt, _price, _collateralDecimals); - return TCR; }
Lines in source code are typically limited to 80 characters, but it’s reasonable to stretch beyond this limit when need be as monitor screens theses days are comparatively larger. Considering the files will most likely reside in GitHub that will have a scroll bar automatically kick in when the length is over 164 characters, all code lines and comments should be split when/before hitting this length. Keep line width to max 120 characters for better readability where possible.
Here are some of the instances entailed:
File: BorrowerOperations.sol#L172 File: BorrowerOperations.sol#L268 File: BorrowerOperations.sol#L282 File: BorrowerOperations.sol#L343 File: BorrowerOperations.sol#L511
initialize()
of CollateralConfig.solWhen initializing config struct variables, the function does not check if _MCRs[i]
is smaller than _CCRs[i]
. Consider having the function refactored as follows to avoid input error considering these state variables can only be initialized once:
File: CollateralConfig.sol#L66-L71
require(_MCRs[i] >= MIN_ALLOWED_MCR, "MCR below allowed minimum"); config.MCR = _MCRs[i]; require(_CCRs[i] >= MIN_ALLOWED_CCR, "CCR below allowed minimum"); config.CCR = _CCRs[i]; + require(_CCRs[i] >= _MCRs[i], "CCR below MCR");
uint256
over uint
Across the codebase, there are numerous instances of uint
, as opposed to uint256
. In favor of explicitness, consider replacing all instances of uint
with uint256
.
Here are the contract instances entailed:
File: BorrowerOperations.sol File: TroveManager.sol File: ActivePool.sol File: StabilityPool.sol File: CommunityIssuance.sol File: LQTYStaking.sol File: LUSDToken.sol
File: BorrowerOperations.sol#L651
- "Max fee percentage must less than or equal to 100%"); + "Max fee percentage must be less than or equal to 100%");
According to Solidity's Style Guide below:
https://docs.soliditylang.org/en/v0.8.17/style-guide.html
In order to help readers identify which functions they can call, and find the constructor and fallback definitions more easily, functions should be grouped according to their visibility and ordered in the following manner:
constructor, receive function (if exists), fallback function (if exists), external, public, internal, private
And, within a grouping, place the view
and pure
functions last.
Additionally, inside each contract, library or interface, use the following order:
type declarations, state variables, events, modifiers, functions
Consider adhering to the above guidelines for all contract instances entailed.
For some source-units the compiler version pragma is very unspecific, i.e. ^0.8.0. While this often makes sense for libraries to allow them to be included with multiple different versions of an application, it may be a security risk for the actual application implementation itself. A known vulnerable compiler version may accidentally be selected or security tools might fall-back to an older compiler version ending up actually checking a different EVM compilation that is ultimately deployed on the blockchain.
Avoid floating pragmas where possible. It is highly recommend pinning a concrete compiler version (latest without security issues) in at least the top-level “deployed” contracts to make it unambiguous which compiler version is being used. Rule of thumb: a flattened source-unit should have at least one non-floating concrete solidity compiler version pragma.
Here are the contract instances entailed:
File: ReaperVaultV2.sol File: ReaperVaultERC4626.sol File: ReaperBaseStrategyv4.sol File: ReaperStrategyGranarySupplyOnly.sol
Non-constant state variables should also be conventionally camel cased where possible. If snake case is preferred/adopted, consider lower casing them.
Here are some of the instances entailed:
86: mapping (address => mapping (address => Trove)) public Troves; 116: mapping (address => address[]) public TroveOwners;
delete
to clear variablesdelete a
assigns the initial value for the type to a
. i.e. for integers it is equivalent to a = 0
, but it can also be used on arrays, where it assigns a dynamic array of length zero or a static array of the same length with all elements reset. For structs, it assigns a struct with all members reset. Similarly, it can also be used to set an address to zero address or a boolean to false. It has no effect on whole mappings though (as the keys of mappings may be arbitrary and are generally unknown). However, individual keys and what they map to can be deleted: If a
is a mapping, then delete a[x]
will delete the value stored at x.
The delete key better conveys the intention and is also more idiomatic.
For instance, the a[x] = 0
instance below may be refactored as follows:
- Troves[_borrower][_collateral].stake = 0; + delete Troves[_borrower][_collateral].stake;
#0 - c4-judge
2023-03-10T10:29:36Z
trust1995 marked the issue as grade-b
#1 - c4-sponsor
2023-03-28T21:34:09Z
0xBebis marked the issue as sponsor acknowledged
🌟 Selected for report: c3phas
Also found by: 0x3b, 0x6980, 0x73696d616f, 0xSmartContract, 0xackermann, 0xhacksmithh, 0xsomeone, Bnke0x0, Bough, Budaghyan, Darshan, DeFiHackLabs, Deivitto, GalloDaSballo, JCN, LethL, Madalad, MiniGlome, Morraez, P-384, PaludoX0, Phantasmagoria, Praise, RHaO-sec, Rageur, RaymondFam, ReyAdmirado, Rickard, Rolezn, SaeedAlipoor01988, Saintcode_, Sathish9098, TheSavageTeddy, Tomio, Viktor_Cortess, abiih, arialblack14, atharvasama, banky, codeislight, cryptonue, ddimitrov22, dec3ntraliz3d, descharre, dharma09, emmac002, favelanky, hl_, hunter_w3b, kaden, kodyvim, matrix_0wl, oyc_109, pavankv, scokaf, seeu, yamapyblack
42.0697 USDC - $42.07
assert()
The following assertion of vars.compositeDebt != 0
is unnecessary. Even if _LUSDAmount
has accidentally been inputted as 0
making vars.LUSDFee == 0
too, _getCompositeDebt()
is going to have the constant LUSD_GAS_COMPENSATION
which is 10e18
added to vars.compositeDebt
.
Consider removing it from openTrove()
to save gas on function call and also contract deployment:
File: BorrowerOperations.sol#L197
- assert(vars.compositeDebt > 0);
Similarly, asserting MIN_NET_DEBT
greater than zero in setAddresses()
of BorrowerOperations.sol is unnecessary since the constant has been assigned 90e18 in LiquityBase.sol:
File: BorrowerOperations.sol#L128
- assert(MIN_NET_DEBT > 0);
As the solidity EVM works with 32 bytes, most variables work fine with less than 32 bytes and may be packed inside a struct when sitting next to each other so that they can be stored in the same slot, this saves gas when writing to storage ~2000 gas.
For instance, struct LocalVariables_OuterLiquidationFunction
of TroveManager.sol may be refactored as follows:
File: TroveManager.sol#L129-L138
struct LocalVariables_OuterLiquidationFunction { - uint256 collDecimals; + uint128 collDecimals; + bool recoveryModeAtStart; - uint256 collCCR; - uint256 collMCR; + uint128 collCCR; + uint128 collMCR; - uint price; - uint LUSDInStabPool; + uint128 price; + uint128 LUSDInStabPool; - bool recoveryModeAtStart; - uint liquidatedDebt; - uint liquidatedColl; + uint128 liquidatedDebt; + uint128 liquidatedColl; }
This alone will save ~8000 gas with 8 slots of storage reduced to 4 slots.
Internal functions entailing only one line of code may be embedded inline with the function logic invoking it.
For instance, the following code line may be refactored as follows to save gas both on function calls and contract deployment considering _requireNonZeroDebtChange
is only called once in BorrowerOperations.sol by _adjustTrove()
and not anywhere else:
File: BorrowerOperations.sol#L294
- _requireNonZeroDebtChange(_LUSDChange); + require(_LUSDChange > 0, "BorrowerOps: Debt increase requires non-zero debtChange");
_requireValidMaxFeePercentage()
can be simplifiedIn BorrowerOperations.sol, _triggerBorrowingFee()
involving _maxFeePercentage
will only be invoked in openTrove()
and _adjustTrove()
when isRecoveryMode == false
. As such, only the else clause of the if else logic in _requireValidMaxFeePercentage()
is needed to match up with the logic flow. The function entailed (along with the calling code lines) should be refactored as follows to save gas both on function calls and contract deployment:
File: BorrowerOperations.sol#L184 File: BorrowerOperations.sol#L293
- _requireValidMaxFeePercentage(_maxFeePercentage, isRecoveryMode); + _requireValidMaxFeePercentage(_maxFeePercentage);
File: BorrowerOperations.sol#L648-L656
- function _requireValidMaxFeePercentage(uint _maxFeePercentage, bool _isRecoveryMode) internal pure { + function _requireValidMaxFeePercentage(uint _maxFeePercentage) internal pure { - if (_isRecoveryMode) { - require(_maxFeePercentage <= DECIMAL_PRECISION, - "Max fee percentage must less than or equal to 100%"); - } else { require(_maxFeePercentage >= BORROWING_FEE_FLOOR && _maxFeePercentage <= DECIMAL_PRECISION, "Max fee percentage must be between 0.5% and 100%"); - } }
Consider removing unused imports to help save gas on contract deployment.
Here is an instance entailed:
- import "./Dependencies/console.sol";
Consider having the logic of a modifier embedded through a private function to reduce contract size if need be. A private
visibility that saves more gas on function calls than the internal
visibility is adopted because the modifier will only be making this call inside the contract.
For instance, the modifier instance below may be refactored as follows:
File: CollateralConfig.sol#L128-L131
+ function _checkCollateral() private view { + require(collateralConfig[_collateral].allowed, "Invalid collateral address"); + } modifier checkCollateral() { - require(collateralConfig[_collateral].allowed, "Invalid collateral address"); + _checkCollateral(); _; }
A storage pointer is cheaper since copying a state struct in memory would incur as many SLOADs and MSTOREs as there are slots. In another words, this causes all fields of the struct/array to be read from storage, incurring a Gcoldsload (2100 gas) for each field of the struct/array, and then further incurring an additional MLOAD rather than a cheap stack read. As such, declaring the variable with the storage keyword and caching any fields that need to be re-read in stack variables will be much cheaper, involving only Gcoldsload for all associated field reads. Read the whole struct/array into a memory variable only when it is being returned by the function, passed into a function that requires memory, or if the array/struct is being read from another memory array/struct.
For instance, the specific example below may be refactored as follows:
File: TroveManager.sol#L722-L723
- LocalVariables_OuterLiquidationFunction memory vars; + LocalVariables_OuterLiquidationFunction storage vars; - LiquidationTotals memory totals; + LiquidationTotals storage totals;
Non-user specific structs intended to be overwritten in the next function call utilizing them for caching memory variables may be removed from the contract to significantly reduce storage slots on top of enhancing gas optimization. This should be done if "CompilerError: Stack too deep" is not going to be encountered.
For instance, struct LocalVariables_openTrove
and ContractsCache
are neither having users mapped to them in BorrowerOperations.sol nor intended to be inputted as struct instances in function parameters. In lieu of this adoption, simply declare the struct variables separately as locally cached variables for corresponding assignments or function parameter inputs.
In the EVM, there is no opcode for non-strict inequalities (>=, <=) and two operations are performed (> + = or < + =).
As an example, the following >=
inequality instance may be refactored as follows:
- if (timePassed >= SECONDS_IN_ONE_MINUTE) { // Rationale for subtracting 1 on the right side of the inequality: // x >= 10; [10, 11, 12, ...] // x > 10 - 1 is the same as x > 9; [10, 11, 12 ...] + if (timePassed > SECONDS_IN_ONE_MINUTE - 1) {
Similarly, the <=
instance below may be replaced with <
as follows:
- if (_ICR <= _100pct) { // Rationale for adding 1 on the right side of the inequality: // x <= 10; [10, 9, 8, ...] // x < 10 + 1 is the same as x < 11; [10, 9, 8 ...] + if (_ICR < _100pct + 1) {
||
costs less gas than its equivalent &&
Rule of thumb: (x && y)
is (!(!x || !y))
Even with the 10k Optimizer enabled: ||
, OR conditions cost less than their equivalent &&
, AND conditions.
As an example, the &&
instance below may be refactored as follows:
Note: Comment on the changes made for better readability where deemed fit.
- if (vars.ICR >= vars.collMCR && vars.remainingLUSDInStabPool == 0) { continue; } + if (!(vars.ICR < vars.collMCR || vars.remainingLUSDInStabPool != 0)) { continue; }
Instead of using the &&
operator in a single require statement to check multiple conditions, using multiple require statements with 1 condition per require statement will save 3 GAS per &&
.
For instance, the following code line may be refactored as follows:
- require (TroveOwnersArrayLength > 1 && sortedTroves.getSize(_collateral) > 1); + require (TroveOwnersArrayLength > 1); + require (sortedTroves.getSize(_collateral) > 1);
You can have further advantages in term of gas cost by simply using named return values as temporary local variable.
For instance, the code block below may be refactored as follows:
File: TroveManager.sol#L1397-L1423
function updateBaseRateFromRedemption( uint _collateralDrawn, uint _price, uint256 _collDecimals, uint _totalLUSDSupply - ) external override returns (uint) { + ) external override returns (uint newBaseRate) { _requireCallerIsRedemptionHelper(); uint decayedBaseRate = _calcDecayedBaseRate(); /* Convert the drawn collateral back to LUSD at face value rate (1 LUSD:1 USD), in order to get * the fraction of total supply that was redeemed at face value. */ uint redeemedLUSDFraction = LiquityMath._getScaledCollAmount(_collateralDrawn, _collDecimals).mul(_price).div(_totalLUSDSupply); uint newBaseRate = decayedBaseRate.add(redeemedLUSDFraction.div(BETA)); newBaseRate = LiquityMath._min(newBaseRate, DECIMAL_PRECISION); // cap baseRate at a maximum of 100% //assert(newBaseRate <= DECIMAL_PRECISION); // This is already enforced in the line above assert(newBaseRate > 0); // Base rate is always non-zero after redemption // Update the baseRate state variable baseRate = newBaseRate; emit BaseRateUpdated(newBaseRate); _updateLastFeeOpTime(); - return newBaseRate; }
The order of function will also have an impact on gas consumption. Because in smart contracts, there is a difference in the order of the functions. Each position will have an extra 22 gas. The order is dependent on method ID. So, if you rename the frequently accessed function to more early method ID, you can save gas cost. Please visit the following site for further information:
Before deploying your contract, activate the optimizer when compiling using “solc --optimize --bin sourceFile.sol”. By default, the optimizer will optimize the contract assuming it is called 200 times across its lifetime. If you want the initial contract deployment to be cheaper and the later function executions to be more expensive, set it to “ --optimize-runs=1”. Conversely, if you expect many transactions and do not care for higher deployment cost and output size, set “--optimize-runs” to a high number.
module.exports = { solidity: { version: "0.8.0", settings: { optimizer: { enabled: true, runs: 1000, }, }, }, };
Please visit the following site for further information:
https://docs.soliditylang.org/en/v0.5.4/using-the-compiler.html#using-the-commandline-compiler
Here's one example of instance on opcode comparison that delineates the gas saving mechanism:
for !=0 before optimization PUSH1 0x00 DUP2 EQ ISZERO PUSH1 [cont offset] JUMPI after optimization DUP1 PUSH1 [revert offset] JUMPI
Disclaimer: There have been several bugs with security implications related to optimizations. For this reason, Solidity compiler optimizations are disabled by default, and it is unclear how many contracts in the wild actually use them. Therefore, it is unclear how well they are being tested and exercised. High-severity security issues due to optimization bugs have occurred in the past . A high-severity bug in the emscripten -generated solc-js compiler used by Truffle and Remix persisted until late 2018. The fix for this bug was not reported in the Solidity CHANGELOG. Another high-severity optimization bug resulting in incorrect bit shift results was patched in Solidity 0.5.6. Please measure the gas savings from optimizations, and carefully weigh them against the possibility of an optimization-related bug. Also, monitor the development and adoption of Solidity compiler optimizations to assess their maturity.
#0 - c4-judge
2023-03-09T18:45:18Z
trust1995 marked the issue as grade-b