Platform: Code4rena
Start Date: 14/09/2022
Pot Size: $50,000 USDC
Total HM: 25
Participants: 110
Period: 5 days
Judge: hickuphh3
Total Solo HM: 9
Id: 162
League: ETH
Rank: 17/110
Findings: 4
Award: $713.52
π Selected for report: 1
π Solo Findings: 0
116.6703 USDC - $116.67
Judge has assessed an item in Issue #62 as Medium risk. The relevant finding follows:
#0 - HickupHH3
2022-11-05T01:49:40Z
dup #483
10.4093 USDC - $10.41
https://github.com/code-423n4/2022-09-y2k-finance/blob/ac3e86f07bc2f1f51148d2265cc897e8b494adf7/src/oracles/PegOracle.sol#L63 https://github.com/code-423n4/2022-09-y2k-finance/blob/ac3e86f07bc2f1f51148d2265cc897e8b494adf7/src/Controller.sol#L308 https://github.com/code-423n4/2022-09-y2k-finance/blob/ac3e86f07bc2f1f51148d2265cc897e8b494adf7/src/oracles/PegOracle.sol#L126
Different problems have been found with the use of the oracle that can incur economic losses when the oracle is not consumed in a completely safe way.
Thre problems found are:
timeStamp
check is not correct since in both cases it is done against 0, which would mean that a date of 2 years ago would be valid, so old prices can be taken.function getLatestPrice(address _token) public view returns (int256 nowPrice) { ... if(timeStamp == 0) revert TimestampZero(); return price; }
The latestRoundData
method of the PegOracle
contract calls priceFeed1.latestRoundData();
directly, but does not perform the necessary round or timestamp checks, and delegates them to the caller, but these checks are performed on price2 because it calls getOracle2_Price
in this case, this inconsistency between how it take the price1 and price2 behaves favors human errors when consuming the oracle.
For the timestamp issue, it should be checked like this:
+ uint constant observationFrequency = 1 hours; function getLatestPrice(address _token) public view returns (int256 nowPrice) { ... ( uint80 roundID, int256 price, , uint256 timeStamp, uint80 answeredInRound ) = priceFeed.latestRoundData(); uint256 decimals = 10**(18-(priceFeed.decimals())); price = price * int256(decimals); if(price <= 0) revert OraclePriceZero(); if(answeredInRound < roundID) revert RoundIDOutdated(); - if(timeStamp == 0) + if(timeStamp < block.timestamp - uint256(observationFrequency)) revert TimestampZero(); return price; }
#0 - 3xHarry
2022-09-21T19:16:08Z
@MiguelBits this is a valid issue, as we need to make sure we don't read a stail price!
#1 - HickupHH3
2022-10-15T07:03:17Z
Agree with the issue, but disagree with severity given. Checking for stale prices have historically been given a medium severity rating; there isn't a compelling argument made IMO to increase it to high.
#2 - HickupHH3
2022-10-15T07:29:38Z
I like this issue because it covers the faulty timestamp check that others don't mention, but I'm also favouring #330 for its recommended mitigation step to put the checks in the internal function.
The current meta (at the time of writing) is to unfortunately choose 1, so I'm sticking to this issue as the primary.
π Selected for report: Respx
Also found by: 0x1f8b, 0xDecorativePineapple, 0xNazgul, 0xPanas, 0xSmartContract, 0xc0ffEE, 0xmuxyz, Aymen0909, Bahurum, Bnke0x0, CodingNameKiki, Deivitto, Jeiwan, Lambda, Picodes, PwnPatrol, R2, RaymondFam, Rolezn, Ruhum, Saintcode_, SooYa, Tointer, V_B, ajtra, ak1, async, auditor0517, brgltd, c3phas, carrotsmuggler, cccz, csanuragjain, datapunk, djxploit, durianSausage, eierina, erictee, gogo, imare, joestakey, jonatascm, kv, ladboy233, leosathya, lukris02, oyc_109, pashov, pauliax, rbserver, robee, rokinot, rvierdiiev, scaraven, simon135, unforgiven, wagmi, zzzitron
570.2631 USDC - $570.26
PegOracle
contract assume decimals <= 18 and does not handle > 18 decimals.
Because the pragma used doesn't allow integer underflows, if a token with more than 18 decimals is used, an integer underflow will produce a denial of service.
Some tokens have more than 18 decimals (e.g. YAM-V2 has 24).
This may trigger unexpected reverts due to overflow, posing a liveness risk to the contract.
Reference:
Major severity finding from Consensys Diligence Audit of Defi Saver:
Affected source code:
The following methods have a lack of checks if the received argument is an address, it's good practice in order to reduce human error to check that the address specified in the constructor or initialize is different than address(0)
.
Affected source code for address(0)
:
Affected source code for integer range checks:
_withdrawalFee
must be less than the factor:
Nothing ensures that there are rewards for the users stakes, it can also be stolen from the rewardsToken
by the admin.
Affected source code:
The admin might think that the change has been applied and it is not. If it shouldn't call changeOracle
and maybe due to human error, the admin won't.
if (tokenToOracle[_token] == address(0)) { tokenToOracle[_token] = _oracle; } + else if (tokenToOracle[_token] != _oracle) { + revert(); + }
Affected source code:
The setClaimTVL
, changeTreasury
, changeTimewindow
and changeController
methods do not emit an event when the state changes, something that it's very important for dApps and users.
Affected source code:
The pragma version used are:
pragma solidity 0.8.15;
Note that mixing pragma is not recommended. Because different compiler versions have different meanings and behaviors, it also significantly raises maintenance costs. As a result, depending on the compiler version selected for any given file, deployed contracts may have security issues.
The minimum required version must be 0.8.16; otherwise, contracts will be affected by the following important bug fixes:
Apart from these, there are several minor bug fixes and improvements.
To make it easier to detect the error, these conditions should be reversed, otherwise the user will receive a different error.
+ if(controller == address(0)) + revert ControllerNotSet(); if( IController(controller).getVaultFactory() != address(this) ) revert AddressFactoryNotInController(); - if(controller == address(0)) - revert ControllerNotSet();
Affected source code:
The RewardsFactory
contract replicates the logic of the getHashedIndex
method in some places, it is convenient to call the method since if said calculation is updated, it will have to be changed in several places with the possible human error that this entails.
Affected source code:
Call getHashedIndex
instead replicate the logic in:
It is not recommended that a modifier modify the states of the contract, normally they can be called multiple times by chaining different calls, and it also complicates the readability and auditability of the code, so it is recommended to use any modifier only for validations.
Affected source code:
In SemiFungibleVault
contract, the methods maxDeposit
, maxMint
and maxWithdraw
are not used, if someone understands the contract and modifies these virtual methods, it will not be applied unless they also modify the deposit
and withdraw
methods.
Affected source code:
π Selected for report: pfapostol
Also found by: 0x040, 0x1f8b, 0x4non, 0xNazgul, 0xSmartContract, 0xc0ffEE, 0xkatana, Aymen0909, Bnke0x0, Deivitto, Diana, JAGADESH, KIntern_NA, Lambda, MiloTruck, R2, RaymondFam, Respx, ReyAdmirado, Rohan16, RoiEvenHaim, Rolezn, Ruhum, Saintcode_, Samatak, Sm4rty, SnowMan, Tomio, Tomo, WilliamAmbrozic, _Adam, __141345__, ajtra, ak1, async, c3phas, ch0bu, cryptostellar5, d3e4, delfin454000, dharma09, djxploit, durianSausage, eierina, erictee, fatherOfBlocks, gianganhnguyen, gogo, ignacio, imare, jag, jonatascm, leosathya, lukris02, malinariy, oyc_109, pashov, pauliax, peanuts, peiw, prasantgupta52, robee, rokinot, rotcivegaf, rvierdiiev, seyni, simon135, slowmoses, sryysryy, tnevler, zishansami
16.1756 USDC - $16.18
Shortening revert strings to fit in 32 bytes will decrease deployment time gas and will decrease runtime gas when the revert condition is met.
Revert strings that are longer than 32 bytes require at least one additional mstore, along with additional overhead for computing memory offset, etc.
Proof of concept (without optimizations):
pragma solidity 0.8.15; contract TesterA { function testShortRevert(bool path) public view { require(path, "test error"); } } contract TesterB { function testLongRevert(bool path) public view { require(path, "test big error message, more than 32 bytes"); } }
Gas saving executing: 18 per entry
TesterA.testShortRevert: 21886 TesterB.testLongRevert: 21904
Affected source code:
Custom errors from Solidity 0.8.4
are cheaper than revert strings (cheaper deployment cost and runtime cost when the revert condition is met)
Source Custom Errors in Solidity:
Starting from Solidity v0.8.4
, there is a convenient and gas-efficient way to explain to users why an operation failed through the use of custom errors. Until now, you could already use strings to give more information about failures (e.g., revert("Insufficient funds.");), but they are rather expensive, especially when it comes to deploy cost, and it is difficult to use dynamic information in them.
Custom errors are defined using the error statement, which can be used inside and outside of contracts (including interfaces and libraries).
Proof of concept (without optimizations):
pragma solidity 0.8.15; contract TesterA { function testRevert(bool path) public view { require(path, "test error"); } } contract TesterB { error MyError(string msg); function testError(bool path) public view { if(path) revert MyError("test error"); } }
Gas saving executing: 9 per entry
TesterA.testRevert: 21611 TesterB.testError: 21602
Affected source code:
immutable
It's possible to avoid storage access a save gas using immutable
keyword for the following variables:
It's also better to remove the initial values, because they will be set during the constructor.
Affected source code:
The following state variables can be removed without affecting the logic of the contract since they are not used and/or are redundant.
Affected source code:
Compare a boolean value using == true
or == false
instead of using the boolean value is more expensive.
NOT
opcode it's cheaper than using EQUAL
or NOTEQUAL
when the value it's false, or just the value without == true
when it's true, because it will use less opcodes inside the VM.
Proof of concept (without optimizations):
pragma solidity 0.8.16; contract TesterA { function testEqual(bool a) public view returns (bool) { return a == true; } } contract TesterB { function testNot(bool a) public view returns (bool) { return a; } }
Gas saving executing: 18 per entry for == true
TesterA.testEqual: 21814 TesterB.testNot: 21796
pragma solidity 0.8.16; contract TesterA { function testEqual(bool a) public view returns (bool) { return a == false; } } contract TesterB { function testNot(bool a) public view returns (bool) { return !a; } }
Gas saving executing: 15 per entry for == false
TesterA.testEqual: 21814 TesterB.testNot: 21799
Affected source code:
Use the value instead of == false
:
Use the value instead of != true
:
Use the value instead of == true
:
The following local variables are not necessary and can be avoided by directly using the variable value in the conditional, this will avoid creating variables on the stack and reduce computational cost.
Remove bool isSequencerUp
:
Remove uint256 timeSinceUp
:
Using compound assignment operators for state variables (like State += X
or State -= X
...) it's more expensive than using operator assignment (like State = State + X
or State = State - X
...).
Proof of concept (without optimizations):
pragma solidity 0.8.15; contract TesterA { uint private _a; function testShort() public { _a += 1; } } contract TesterB { uint private _a; function testLong() public { _a = _a + 1; } }
Gas saving executing: 13 per entry
TesterA.testShort: 43507 TesterB.testLong: 43494
Affected source code:
++i
costs less gas compared to i++
or i += 1
++i
costs less gas compared to i++
or i += 1
for unsigned integers, as pre-increment is cheaper (about 5 gas per iteration). This statement is true even with the optimizer enabled.
i++
increments i
and returns the initial value of i
. Which means:
uint i = 1; i++; // == 1 but i == 2
But ++i
returns the actual incremented value:
uint i = 1; ++i; // == 2 and i == 2 too, so no need for a temporary variable
In the first case, the compiler has to create a temporary variable (when used) for returning 1
instead of 2
I suggest using ++i
instead of i++
to increment the value of an uint variable. Same thing for --i
and i--
Keep in mind that this change can only be made when we are not interested in the value returned by the operation, since the result is different, you only have to apply it when you only want to increase a counter.
Affected source code:
An empty block or an empty method generally indicates a lack of logic that itβs unnecessary and should be eliminated, call a method that literally does nothing only consumes gas unnecessarily, if it is a virtual
method which is expects it to be filled by the class that implements it, it is better to use abstract
contracts with just the definition.
Sample code:
pragma solidity >=0.4.0 <0.7.0; abstract contract Feline { function utterance() public virtual returns (bytes32); }
Reference:
Affected source code:
If a variable is not set/initialized, the default value is assumed (0, false
, 0x0 ... depending on the data type). You are simply wasting gas if you directly initialize it with its default value.
Proof of concept (without optimizations):
pragma solidity 0.8.15; contract TesterA { function testInit() public view returns (uint) { uint a = 0; return a; } } contract TesterB { function testNoInit() public view returns (uint) { uint a; return a; } }
Gas saving executing: 8 per entry
TesterA.testInit: 21392 TesterB.testNoInit: 21384
Affected source code:
It's cheaper to store the dynamic value inside a local variable and iterate over it.
Affected source code:
require
instead of assert
The assert()
and require()
functions are a part of the error handling aspect in Solidity. Solidity makes use of state-reverting error handling exceptions. This means all changes made to the contract on that call or any sub-calls are undone if an error is thrown. It also flags an error.
They are quite similar as both check for conditions and if they are not met, would throw an error.
The big difference between the two is that the assert()
function when false, uses up all the remaining gas and reverts all the changes made.
Meanwhile, a require()
function when false, also reverts back all the changes made to the contract but does refund all the remaining gas fees we offered to pay. This is the most common Solidity function used by developers for debugging and error handling.
Affected source code:
In the constructor, store a new immutable variable isrY2K
if the symbol
is rY2K
, this will avoid expensive operations like the following:
function beforeWithdraw(uint256 id, uint256 amount) public view returns (uint256 entitledAmount) { // in case the risk wins aka no depeg event // risk users can withdraw the hedge (that is paid by the hedge buyers) and risk; withdraw = (risk + hedge) // hedge pay for each hedge seller = ( risk / tvl before the hedge payouts ) * tvl in hedge pool // in case there is a depeg event, the risk users can only withdraw the hedge if ( - keccak256(abi.encodePacked(symbol)) == - keccak256(abi.encodePacked("rY2K")) + isrY2K ) { if (!idDepegged[id]) { //depeg event did not happen /* entitledAmount = (amount / idFinalTVL[id]) * idClaimTVL[id] + amount; */ entitledAmount = amount.divWadDown(idFinalTVL[id]).mulDivDown( idClaimTVL[id], 1 ether ) + amount; } else { //depeg event did happen entitledAmount = amount.divWadDown(idFinalTVL[id]).mulDivDown( idClaimTVL[id], 1 ether ); } }
Affected source code: