Platform: Code4rena
Start Date: 12/08/2022
Pot Size: $50,000 USDC
Total HM: 15
Participants: 120
Period: 5 days
Judge: Justin Goro
Total Solo HM: 6
Id: 153
League: ETH
Rank: 4/120
Findings: 3
Award: $3,026.13
π Selected for report: 2
π Solo Findings: 1
π Selected for report: 0x1f8b
2535.6587 USDC - $2,535.66
The method globalPause
is not tested and it doesn't work as expected.
Because the method returns an array (_updatedAddresses
) and has never been initialized, when you want to set its value, it fails.
Recipe:
globalPause
with any valid address.Initialize the _updatedAddresses
array like shown bellow:
function globalPause(address[] memory _addresses) external returns (address[] memory _updatedAddresses) { require(msg.sender == CIRCUIT_BREAKER_ADDRESS, "Circuit Breaker only"); address _pairAddress; uint256 _lengthOfArray = _addresses.length; + _updatedAddresses = new address[](_lengthOfArray); for (uint256 i = 0; i < _lengthOfArray; ) { _pairAddress = _addresses[i]; try IFraxlendPair(_pairAddress).pause() { _updatedAddresses[i] = _addresses[i]; } catch {} unchecked { i = i + 1; } } }
#0 - DrakeEvans
2022-09-06T12:44:41Z
Valid, disagree with severity, this is a convenience function. Pause can still be called on the pairs themselves individually. No user funds at risk, and no users can even touch this function, additionally no loss of functionality in the pairs themselves. Only result is admin having a revert and spin up tx individually.
#1 - gititGoro
2022-10-03T22:54:42Z
Well caught! But definitely a medium severity, given the existence of per pair pausing.
π Selected for report: 0x1f8b
Also found by: 0x52, 0xA5DF, 0xDjango, 0xNazgul, 0xNineDec, 0xSmartContract, 0xmatt, 0xsolstars, Aymen0909, Bnke0x0, CertoraInc, Chom, CodingNameKiki, Deivitto, Dravee, ElKu, EthLedger, Funen, IllIllI, JC, Junnon, Lambda, LeoS, MiloTruck, Noah3o6, PaludoX0, ReyAdmirado, Rohan16, RoiEvenHaim, Rolezn, SaharAP, Sm4rty, SooYa, The_GUILD, TomJ, Waze, Yiko, _Adam, __141345__, a12jmx, ak1, asutorufos, auditor0517, ayeslick, ballx, beelzebufo, berndartmueller, bin2chen, brgltd, c3phas, cRat1st0s, cccz, cryptonue, cryptphi, d3e4, delfin454000, dipp, djxploit, durianSausage, dy, erictee, fatherOfBlocks, gogo, gzeon, hyh, ignacio, kyteg, ladboy233, medikko, mics, minhquanym, oyc_109, pfapostol, rbserver, reassor, ret2basic, robee, sach1r0, simon135, sryysryy, tabish, yac, yash90, zzzitron
448.6417 USDC - $448.64
The pragma version used are:
pragma solidity ^0.8.15;
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.
SafeERC20
mismatch logicsThe SafeERC20
library says:
@title SafeERC20 provides helper functions for safe transfers as well as safe metadata access
But the reality is that metada access is not secure.
The SafeERC20.safeTransfer
and SafeERC20.safeTransferFrom
methods perform a safe check that the address to call is a contract by calling functionCall, this checks doesn't appear in the methods safeSymbol
, safeName
and safeDecimals
, so some of the SafeERC20
library methods are prone to phantom methods attacks.
Proof of Concept:
If you try to call the SafeERC20
methods with the following token 0x000000000000000000000000000000000000000000000
(or any EOA) you can see that the transaction is successful, you have to check that the address is a valid contract and maybe check that the call returns a certain data. Otherwise it is possible to call it and lose tokens or produce undesired errors.
This remind me to this attack https://certik.medium.com/qubit-bridge-collapse-exploited-to-the-tune-of-80-million-a7ab9068e1a0 where we can see:
Affected source code:
Recommended changes:
functionStaticCall
from OpenZeppelin.+import "@openzeppelin/contracts/utils/Address.sol"; ... library SafeERC20 { + using Address for address; /// @notice Provides a safe ERC20.symbol version which returns '???' as fallback string. /// @param token The address of the ERC-20 token contract. /// @return (string) Token symbol. function safeSymbol(IERC20 token) internal view returns (string memory) { - (bool success, bytes memory data) = address(token).staticcall(abi.encodeWithSelector(SIG_SYMBOL)); + (bool success, bytes memory data) = address(token).functionStaticCall(abi.encodeWithSelector(SIG_SYMBOL)); return success ? returnDataToString(data) : "???"; } /// @notice Provides a safe ERC20.name version which returns '???' as fallback string. /// @param token The address of the ERC-20 token contract. /// @return (string) Token name. function safeName(IERC20 token) internal view returns (string memory) { - (bool success, bytes memory data) = address(token).staticcall(abi.encodeWithSelector(SIG_NAME)); + (bool success, bytes memory data) = address(token).functionStaticCall(abi.encodeWithSelector(SIG_NAME)); return success ? returnDataToString(data) : "???"; } /// @notice Provides a safe ERC20.decimals version which returns '18' as fallback value. /// @param token The address of the ERC-20 token contract. /// @return (uint8) Token decimals. function safeDecimals(IERC20 token) internal view returns (uint8) { - (bool success, bytes memory data) = address(token).staticcall(abi.encodeWithSelector(SIG_DECIMALS)); + (bool success, bytes memory data) = address(token).functionStaticCall(abi.encodeWithSelector(SIG_DECIMALS)); return success && data.length == 32 ? abi.decode(data, (uint8)) : 18; }
It's possible to lose the ownership under specific circumstances.
Because an human error it's possible to set a new invalid owner. When you want to change the owner's address it's better to propose a new owner, and then accept this ownership with the new wallet.
Affected source code:
Is not possible to use public
as pair name, if you are using the same default values as was used in deploy
.
Becuase the salt for deploy is "public", is not possible to use the same as a name.
Affected source code:
Recommended changes:
- keccak256(abi.encodePacked(_name)), + keccak256(abi.encodePacked("custom", _name)),
TimeLock
restrictionsEsto permite que llamadas como la que se muestran a continuacion, que se encuentran limitadas al contrato de TIME_LOCK_ADDRESS
, sea posible ser llamadas por el owner, evitando cualquier limitaciΓ³n de tiempo, debido a que en primer lugar no se comprueba que la direccion establecida en setTimeLock
sea un contrato, y segundo, porque podria establecer cualquier contrato, y en la misma llamada hacer la llamada limitada al TimeLock
.
The FraxlendPair
contract has a setTimeLock
method that allows you to modify the TimeLock
contract (TIME_LOCK_ADDRESS
), this call is protected by the onlyOwner
modifier.
This allows the owner to call methods like the one shown below (changeFee
), which are limited to the TIME_LOCK_ADDRESS
contract, avoiding any time constraints, since the established address in setTimeLock
is not checked to be a contract, you could set any contract, and in the same transaction you can call the protected method.
function changeFee(uint32 _newFee) external whenNotPaused { if (msg.sender != TIME_LOCK_ADDRESS) revert OnlyTimeLock();
This can facilitate rogue pool attacks.
Affected source code:
Because Solidity integer division may truncate, it is often preferable to do multiplication before division to prevent precision loss.
Affected source code:
Recommended changes:
uint256 internal constant LTV_PRECISION = 1e5; // 5 decimals uint256 internal constant EXCHANGE_PRECISION = 1e18; ... + uint256 internal constant SOLVENT_PRECISION = EXCHANGE_PRECISION / LTV_PRECISION; // 1e13 ... function _isSolvent(address _borrower, uint256 _exchangeRate) internal view returns (bool) { if (maxLTV == 0) return true; uint256 _borrowerAmount = totalBorrow.toAmount(userBorrowShares[_borrower], true); if (_borrowerAmount == 0) return true; uint256 _collateralAmount = userCollateralBalance[_borrower]; if (_collateralAmount == 0) return false; - uint256 _ltv = (((_borrowerAmount * _exchangeRate) / EXCHANGE_PRECISION) * LTV_PRECISION) / _collateralAmount; + uint256 _ltv = (_borrowerAmount * _exchangeRate) / (_collateralAmount * SOLVENT_PRECISION); return _ltv <= maxLTV; }
Ownable
and Pausable
The contract FraxlendPairCore
is Ownable
and Pausable
, so the owner could resign while the contract is paused, causing a Denial of Service. Owner resignation while the contract is paused should be avoided.
Affected source code:
encode
instead of encodePacked
for hashigUse of abi.encodePacked
is safe, but unnecessary and not recommended. abi.encodePacked
can result in hash collisions when used with two dynamic arguments (string/bytes).
There is also discussion of removing abi.encodePacked
from future versions of Solidity (ethereum/solidity#11593), so using abi.encode
now will ensure compatibility in the future.
Affected source code:
toBorrowAmount
functionThe function toBorrowAmount
is wrong commented.
Affected source code:
Recommended changes:
- /// @notice The ```toBorrtoBorrowAmountowShares``` function converts a given amount of borrow debt into the number of shares + /// @notice The ```toBorrowAmount``` function converts a given number of shares into the amount of borrow debt /// @param _shares Shares of borrow /// @param _roundUp Whether to roundup during division function toBorrowAmount(uint256 _shares, bool _roundUp) external view returns (uint256) { return totalBorrow.toAmount(_shares, _roundUp); }
The FraxlendPairCore
contract constructor takes two arguments, _configData
and _immutables
, however, there are immutables in _configData
and variables in _immutables
.
This may seem confusing, since for example one expects the TIME_LOCK_ADDRESS
to be immutable as it is defined in the _immutables
variable, however it is possible to change it with setTimeLock.
Affected source code:
#0 - gititGoro
2022-10-05T22:40:39Z
Very good report quality! There were some invalid points: 2. Misconfigured or misbehaving tokens are out of scope 6 The precision cap is intentional. This issue was reported as a medium risk bug by another warden and marked invalid. 7. Owners resigning seems beyond the scope of the audit. FraxLend will be managed by a DAO like other Frax products. Even without explicit documentation, for a Frax product, this should be the assumed default. This is if you were unable to contact the sponsor and verify explicitly that a human controlled EOA would be the owner, a very uncommon circumstance for a large mainnet dapp.
π Selected for report: IllIllI
Also found by: 0x1f8b, 0xA5DF, 0xDjango, 0xNazgul, 0xSmartContract, 0xackermann, 0xbepresent, 0xc0ffEE, 0xkatana, 2997ms, Amithuddar, Aymen0909, Bnke0x0, Chinmay, Chom, CodingNameKiki, Deivitto, Diraco, Dravee, ElKu, EthLedger, Fitraldys, Funen, IgnacioB, JC, Junnon, Lambda, LeoS, Metatron, MiloTruck, Noah3o6, NoamYakov, PaludoX0, Randyyy, ReyAdmirado, Rohan16, Rolezn, Ruhum, SaharAP, Sm4rty, SooYa, TomJ, Tomio, Waze, Yiko, _Adam, __141345__, a12jmx, ajtra, ak1, asutorufos, ballx, brgltd, c3phas, cRat1st0s, carlitox477, chrisdior4, d3e4, delfin454000, dharma09, djxploit, durianSausage, erictee, fatherOfBlocks, find_a_bug, flyx, francoHacker, gerdusx, gogo, gzeon, hakerbaya, ignacio, jag, kyteg, ladboy233, ltyu, m_Rassska, medikko, mics, mrpathfindr, newfork01, nxrblsrpr, oyc_109, pfapostol, rbserver, reassor, ret2basic, robee, sach1r0, saian, simon135, sryysryy, zeesaw
41.8254 USDC - $41.83
It's cheaper to store the length of the array inside a local variable and iterate over it.
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 integer, 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--
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:
> 0
is less efficient than != 0
for unsigned integersAlthough it would appear that > 0
is less expensive than != 0,
this is only true in the absence of the optimizer and outside of a need statement. Gas will be saved if the optimizer is enabled at 10k and you're in a require statement.
Reference:
Affected source code:
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:
returnDataToString
Since the value has already been checked to be != 0 previously, there is no need to do it again.
Affected source code:
Recommended changes:
function returnDataToString(bytes memory data) internal pure returns (string memory) { if (data.length >= 64) { return abi.decode(data, (string)); } else if (data.length == 32) { uint8 i = 0; while (i < 32 && data[i] != 0) { i++; } bytes memory bytesArray = new bytes(i); - for (i = 0; i < 32 && data[i] != 0; i++) { + for (uint x; x < i; x++) { - bytesArray[i] = data[i]; + bytesArray[x] = data[x]; } return string(bytesArray); } else { return "???"; } }
abi.encodeWithSelector
resultIt is not necessary to call abi.encodeWithSelector
with no data each time, it is cheaper to cache the byte result.
Affected source code:
Recommended changes:
library SafeERC20 { - bytes4 private constant SIG_SYMBOL = 0x95d89b41; // symbol() - bytes4 private constant SIG_NAME = 0x06fdde03; // name() - bytes4 private constant SIG_DECIMALS = 0x313ce567; // decimals() + bytes private immutable SIG_SYMBOL = abi.encodeWithSelector(0x95d89b41); // symbol() + bytes private immutable SIG_NAME = abi.encodeWithSelector(0x06fdde03); // name() + bytes private immutable SIG_DECIMALS = abi.encodeWithSelector(0x313ce567); // decimals() ... function safeSymbol(IERC20 token) internal view returns (string memory) { + (bool success, bytes memory data) = address(token).staticcall(SIG_SYMBOL); - (bool success, bytes memory data) = address(token).staticcall(abi.encodeWithSelector(SIG_SYMBOL)); return success ? returnDataToString(data) : "???"; } ... function safeName(IERC20 token) internal view returns (string memory) { + (bool success, bytes memory data) = address(token).staticcall(SIG_NAME); - (bool success, bytes memory data) = address(token).staticcall(abi.encodeWithSelector(SIG_NAME)); return success ? returnDataToString(data) : "???"; } ... function safeDecimals(IERC20 token) internal view returns (uint8) { + (bool success, bytes memory data) = address(token).staticcall(SIG_DECIMALS); - (bool success, bytes memory data) = address(token).staticcall(abi.encodeWithSelector(SIG_DECIMALS)); return success && data.length == 32 ? abi.decode(data, (uint8)) : 18; } ...
bool
to uint256
can save gasBecause each write operation requires an additional SLOAD
to read the slot's contents, replace the bits occupied by the boolean, and then write back, booleans
are more expensive than uint256
or any other type that uses a complete word. This cannot be turned off because it is the compiler's defense against pointer aliasing and contract upgrades.
Reference:
Affected source code:
Use constant instead of storage/computed values in:
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:
abi.decode
resultsdeploy
should decode _configData
as an struct. In this way, we can avoid the third decoding in the _deployFirst
, _deploySecond
and _log
methods.
Affected source code:
struct ConfigData { address _asset, address _collateral, address _oracleMultiply, address _oracleDivide, uint256 _oracleNormalization, address _rateContract, bytes memory _rateInitData } function _deploySecond( string memory _name, address _pairAddress, ConfigData memory _configData, address[] memory _approvedBorrowers, address[] memory _approvedLenders ) private returns (ConfigData memory data) { ... } function _logDeploy( string memory _name, address _pairAddress, ConfigData memory _configData, uint256 _maxLTV, uint256 _liquidationFee, uint256 _maturityDate ) private { ... }
immutable
to cache constant dataIt is not necessary to continuously calculate the symbol
, it can be cached during the contract deployment.
Affected source code:
Recommended changes:
+ string private immutable _symbol; + constructor( bytes memory _configData, bytes memory _immutables, uint256 _maxLTV, uint256 _liquidationFee, uint256 _maturityDate, uint256 _penaltyRate, bool _isBorrowerWhitelistActive, bool _isLenderWhitelistActive ) FraxlendPairCore( _configData, _immutables, _maxLTV, _liquidationFee, _maturityDate, _penaltyRate, _isBorrowerWhitelistActive, _isLenderWhitelistActive ) ERC20("", "") Ownable() Pausable() - {} + { + _symbol = string(abi.encodePacked("FraxlendV1 - ", collateralContract.safeSymbol(), "/", assetContract.safeSymbol())); + } function symbol() public view override(ERC20, IERC20Metadata) returns (string memory) { // prettier-ignore // solhint-disable-next-line max-line-length - return string(abi.encodePacked("FraxlendV1 - ", collateralContract.safeSymbol(), "/", assetContract.safeSymbol())); + return _symbol; }
It is possible to save accesses to storage by caching the state variables.
Affected source code:
Recommended changes:
/// @notice The ```setApprovedBorrowers``` function sets a given array of addresses to the whitelist /// @dev Cannot black list self /// @param _borrowers The addresses whos status will be set /// @param _approval The approcal status function setApprovedBorrowers(address[] calldata _borrowers, bool _approval) external approvedBorrower { for (uint256 i = 0; i < _borrowers.length; i++) { // Do not set when _approval == false and _borrower == msg.sender - if (_approval || _borrowers[i] != msg.sender) { - approvedBorrowers[_borrowers[i]] = _approval; - emit SetApprovedBorrower(_borrowers[i], _approval); + address _borrower = _borrowers[i]; + if (_approval || _borrower != msg.sender) { + approvedBorrowers[_borrower] = _approval; + emit SetApprovedBorrower(_borrower, _approval); } } }
Using compound assignment operator for state variables (like State += X
or State -= X
...) it's more expensive than use 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:
It is possible to reduce the number of operations inside _isSolvent
by creating a new constant with value 1e13
. We can now divide by just this constant instead of multiplying by 1e18
and dividing by 1e5
, with the loss of precision that this causes.
Affected source code:
Recommended changes:
uint256 internal constant LTV_PRECISION = 1e5; // 5 decimals uint256 internal constant EXCHANGE_PRECISION = 1e18; ... + uint256 internal constant SOLVENT_PRECISION = EXCHANGE_PRECISION / LTV_PRECISION; // 1e13 ... function _isSolvent(address _borrower, uint256 _exchangeRate) internal view returns (bool) { if (maxLTV == 0) return true; uint256 _borrowerAmount = totalBorrow.toAmount(userBorrowShares[_borrower], true); if (_borrowerAmount == 0) return true; uint256 _collateralAmount = userCollateralBalance[_borrower]; if (_collateralAmount == 0) return false; - uint256 _ltv = (((_borrowerAmount * _exchangeRate) / EXCHANGE_PRECISION) * LTV_PRECISION) / _collateralAmount; + uint256 _ltv = (_borrowerAmount * _exchangeRate) / (_collateralAmount * SOLVENT_PRECISION); return _ltv <= maxLTV; }
#0 - gititGoro
2022-10-09T11:29:48Z
Very high quality reporting.