Platform: Code4rena
Start Date: 19/04/2022
Pot Size: $30,000 USDC
Total HM: 10
Participants: 43
Period: 3 days
Judges: moose-code, JasoonS
Total Solo HM: 7
Id: 90
League: ETH
Rank: 4/43
Findings: 5
Award: $3,835.90
🌟 Selected for report: 4
🚀 Solo Findings: 1
The ORDERER_ROLE
role has the ability to arbitrarily transfer user funds, and this role is shared between both the orderer
and people who can rebalance the index.
Even if the owner is benevolent the fact that there is a rug vector available may negatively impact the protocol's reputation. See this example where a similar finding has been flagged as a high-severity issue. I've downgraded this instance to be a medium since it requires a malicious manager.
The role is given to the orderer
so it has the ability to add/remove funds during Uniswap operations:
File: contracts/vToken.sol (lines 80-87)
/// @inheritdoc IvToken function transferFrom( address _from, address _to, uint _shares ) external override nonReentrant onlyRole(ORDERER_ROLE) { _transfer(_from, _to, _shares); }
The role is also required to initiate rebalances: File: contracts/TopNMarketCapIndex.sol (lines 67-68)
/// @notice Reweighs index assets according to the latest market cap data for specified category function reweight() external override onlyRole(ORDERER_ROLE) {
File: contracts/TrackedIndex.sol (lines 56-57)
/// @notice Reweighs index assets according to the latest market cap data function reweight() external override onlyRole(ORDERER_ROLE) {
It is not necessary for the person/tool initiating reweights to also have the ability to arbitrarily transfer funds, so they should be separate roles. If the orderer
also needs to be able to reweight, the orderer
should also be given the new role.
Code inspection
Split the role into two, and only give the ORDERER_ROLE
role to the orderer
#0 - jn-lp
2022-05-11T13:54:56Z
ORDERER_ROLE
role is only given to Orderer contract by multisig, which must have the ability to reweight
indices as well as to transferFrom
on vToken contract
#1 - moose-code
2022-05-23T15:04:06Z
I agree with the warden at the very least there is only benefit in splitting this role out appropriately into two roles. There is likely a case where the ordered and index rebalancers aren't the same.
22.0499 USDC - $22.05
Stale prices can lead to the incorrect valuation of assets
The code does not check the other data returned from latestRoundData()
which must be used to ensure that the data is not stale and that the price is valid
File: contracts/ChainlinkPriceOracle.sol (lines 83-86)
(, int basePrice, , , ) = baseAggregator.latestRoundData(); (, int quotePrice, , , ) = assetInfo.aggregator.latestRoundData(); require(basePrice > 0 && quotePrice > 0, "ChainlinkPriceOracle: NEGATIVE");
Code inspection
In addition to checking that the prices is above zero, code that uses Chainlink data must check that the answeredInRound
is greater than or equal to the expected one, and that the timestamp
is non-zero, indicating that the round is complete
#0 - olivermehr
2022-05-02T20:25:27Z
duplicate of issue #1
🌟 Selected for report: IllIllI
If an index has any inactive assets with the role SKIPPED_ASSET_ROLE
, a user can repeatedly deposit and withdraw assets, always getting the skipped asset without having to deposit any
During minting, any asset that has the 'skipped' role is excluded from the checks of assets deposited: File: contracts/IndexLogic.sol (lines 60-70)
for (uint i; i < inactiveAssets.length(); ++i) { if (!IAccessControl(registry).hasRole(SKIPPED_ASSET_ROLE, inactiveAssets.at(i))) { uint lastBalanceInAsset = IvToken( IvTokenFactory(vTokenFactory).createOrReturnVTokenOf(inactiveAssets.at(i)) ).lastAssetBalanceOf(address(this)); lastAssetBalanceInBase += lastBalanceInAsset.mulDiv( FixedPoint112.Q112, oracle.refreshedAssetPerBaseInUQ(inactiveAssets.at(i)) ); } }
During burning, however, there's a bug that only skips if there are 'blacklisted' assets: File: contracts/IndexLogic.sol (lines 125-140)
for (uint i; i < length + inactiveAssets.length(); ++i) { address asset = i < length ? assets.at(i) : inactiveAssets.at(i - length); if (containsBlacklistedAssets && IAccessControl(registry).hasRole(SKIPPED_ASSET_ROLE, asset)) { continue; } IvToken vToken = IvToken(IvTokenFactory(vTokenFactory).vTokenOf(asset)); uint indexAssetBalance = vToken.balanceOf(address(this)); uint accountBalance = (value * indexAssetBalance) / totalSupply(); if (accountBalance == 0) { continue; } // calculate index value in vault to be burned vToken.transfer(address(vToken), accountBalance); vToken.burn(_recipient);
This means that users will be passed back inactive skipped assets even if they never deposited any
Code inspection
I believe the &&
was meant to be a ||
in the SKIPPED_ASSET_ROLE
in the code block directly above. Changing the code to be that way would be the fix.
#0 - jn-lp
2022-05-11T14:07:58Z
That’s totally expected behavior. We want to get rid of the dust of skipped assets in our index
#1 - moose-code
2022-05-24T12:00:26Z
Awarding the warden here since the documentation of the contest should've clearly mentioned that this is intentional behavior for skipped assets to be able to be drained. Well worth the warden bringing this up. This is well within the scope of the contest and its possible old assets may not be dust and contain material value.
🌟 Selected for report: IllIllI
Also found by: 0v3rf10w, 0xDjango, 0xkatana, Dravee, Kenshin, Tadashi, TerrierLover, abhinavmir, defsec, ellahi, fatima_naz, foobar, gzeon, hyh, joestakey, kebabsec, kenta, minhquanym, oyc_109, rayn, robee, sseefried, xpriment626, z3s
598.6632 USDC - $598.66
require()
should be used instead of assert()
assert(minAmountInBase != type(uint).max);
Transfers the current balance if there is less available, rather than the usual reverting
/// @param _amount Amount of assets to transfer
The interface and the function should require a start index and a lenght, so that the index composition can be fetched in batches without running out of gas. If there are thousands of index components (e.g. like the Wilshire 5000 index), the function may revert
function anatomy() external view override returns (address[] memory _assets, uint8[] memory _weights) { _assets = assets.values(); _weights = new uint8[](_assets.length); for (uint i; i < _assets.length; ++i) { _weights[i] = weightOf[_assets[i]]; } }
Checking for length greater than one is useless because the caller can just pass a weighting of zero for the second asset in order to exclude it
_updatedAssets.length > 1 &&
If two indexes share the same registry, it's not possible to separately apply SKIPPED_ASSET_ROLE
for one but not the other. It's not always clear during index creation whether there will be circumstances that affect one but not the other index
registry = IIndexFactory(_factory).registry();
The README.md
talks about the fact that the orderer splits up orders to reduce price impact. This means that either the orderer
has a slippage bounds which can DOSed with sandwich attacks, or the code uses some sort of VWAP/TWAP, which can also be gamed with flash loans submitted for every slice of the order
IOrderer(orderer).reduceOrderAsset(asset, totalSupply() - value, totalSupply());
return
statement when the function defines a named return variable, is redundantreturn z_;
return result;
return result;
require()
/revert()
statements should have descriptive reason stringsrequire(denominator > 0);
require(denominator > prod1);
require(result < type(uint256).max);
constant
s should be defined rather than using magic numbers_mint(address(0xdead), IndexLibrary.INITIAL_QUANTITY);
uint256 inv = (3 * denominator) ^ 2;
uint256 internal constant Q112 = 0x10000000000000000000000000000;
Use a solidity version of at least 0.8.12 to get string.concat()
to be used instead of abi.encodePacked(<str>,<str>)
pragma solidity >=0.8.7;
const
/immutable
variablesIf the variable needs to be different based on which class it comes from, a view
/pure
function should be used instead (e.g. like this).
bytes32 private REWEIGHT_INDEX_ROLE;
NAV.Data internal _NAV;
// SPDX-License-Identifier: GPL-2.0-or-later
// SPDX-License-Identifier: GPL-2.0-or-later
/// @notice Adds `_asset` to the oracle /// @param _asset Asset's address /// @param _asset Asset aggregator's address function addAsset(address _asset, address _assetAggregator) external;
Missing: @param _assetAggregator
/// @notice Minting fee in base point format /// @return Returns minting fee in base point (BP) format function mintingFeeInBPOf(address _index) external view returns (uint16);
Missing: @param _index
/// @notice Burning fee in base point format /// @return Returns burning fee in base point (BP) format function burningFeeInBPOf(address _index) external view returns (uint16);
Missing: @param _index
/// @notice AUM scaled per seconds rate /// @return Returns AUM scaled per seconds rate function AUMScaledPerSecondsRateOf(address _index) external view returns (uint);
Missing: @param _index
/// @notice Updates and returns asset per base /// @return Asset per base in UQ function refreshedAssetPerBaseInUQ(address _asset) external returns (uint);
Missing: @param _asset
/// @notice Returns last asset per base /// @return Asset per base in UQ function lastAssetPerBaseInUQ(address _asset) external view returns (uint);
Missing: @param _asset
/// @notice Creates or returns address of previously created vToken for the given asset /// @param _asset Asset to create or return vToken for function createOrReturnVTokenOf(address _asset) external returns (address);
Missing: @return
/// @notice Returns amount of assets for the given account with the given shares amount /// @return Amount of assets for the given account with the given shares amount function assetDataOf(address _account, uint _shares) external view returns (AssetData memory);
Missing: @param _account
@param _shares
/// @notice Transfer `_amount` of shares between given addresses /// @param _from Account to send shares from /// @param _to Account to send shares to /// @param _amount Amount of shares to send function transfer( Data storage self, address _from, address _to, uint _amount
Missing: @param self
/// @param _balance New shares maximum limit /// @param _recipient Recipient that will receive minted shares function mint( Data storage self, uint _balance, address _recipient ) internal returns (uint shares) {
Missing: @return
/// @param self Data structure reference /// @param _balance Shares balance function burn(Data storage self, uint _balance) internal returns (uint amount) {
Missing: @return
indexed
fieldsEach event
should use three indexed
fields if there are three or more fields
event UpdateAnatomy(address asset, uint8 weight);
event VTokenTransfer(address indexed from, address indexed to, uint amount);
/// @notice Contains event for aatomy update
aatomy
/// @title Rewightable index interface
Rewightable
// correct result modulo 2**256. Since the precoditions guarantee
precoditions
_shares
whereas the others are named _amount
uint _shares
Rename to constainsBlockedAssets
bool containsBlacklistedAssets;
#0 - moose-code
2022-05-23T12:03:05Z
Excellent report. Well thought out, deep understanding.
#1 - liveactionllama
2022-06-07T16:52:29Z
Per discussion with judge @JasoonS - they agree with the severities outlined by the warden in this issue.
#2 - JasoonS
2022-06-07T17:39:20Z
Thanks @liveactionllama
🌟 Selected for report: IllIllI
Also found by: 0v3rf10w, 0xDjango, 0xNazgul, 0xkatana, Dravee, Kenshin, MaratCerby, Tadashi, TerrierLover, Tomio, TrungOre, defsec, ellahi, fatherOfBlocks, fatima_naz, gzeon, joestakey, kenta, minhquanym, oyc_109, rayn, rfa, robee, simon135, slywaters, windhustler, z3s
281.9792 USDC - $281.98
immutable
Avoids a Gsset (20000 gas)
bytes32 private REWEIGHT_INDEX_ROLE;
address public base;
address public registry;
uint8 private baseDecimals;
If variables occupying the same slot are both written the same function or by the constructor, avoids a separate Gsset (20000 gas). Reads of the variables are also cheaper
address public base;
Variable ordering with 3 slots instead of the current 4: mapping(32):priceOracleOf, address(20):base, uint8(1):baseDecimals, address(20):registry
The instances below point to the second access of a state variable within a function. Caching will replace each Gwarmaccess (100 gas) with a much cheaper stack read. Less obvious optimizations include having local storage variables of mappings within state variable mappings or mappings within state variable structs, having local storage variables of structs within mappings, or having local caches of state variable contracts/addresses.
return IPriceOracle(priceOracleOf[_asset]).refreshedAssetPerBaseInUQ(_asset);
return IPriceOracle(priceOracleOf[_asset]).lastAssetPerBaseInUQ(_asset);
asset
pointer for next iteration of the loop)address asset = path[i + 1];
asset
pointer for next iteration of the loop)address asset = path[i + 1];
uint32 timeElapsed = blockTimestamp - blockTimestampLast;
IERC20(asset).safeTransfer(_recipient, Math.min(_amount, balance));
Caching will replace each Gwarmaccess (100 gas) with a much cheaper stack read.
if (weightOf[assets.at(i)] == 0) {
uint availableAssets = IvToken(IvTokenFactory(vTokenFactory).createOrReturnVTokenOf(assets.at(i)))
uint availableAssets = IvToken(IvTokenFactory(vTokenFactory).createOrReturnVTokenOf(assets.at(i)))
x = x + y
is cheaper than x += y
self.balanceOf[_from] -= _amount;
self.balanceOf[_to] += _amount;
<array>.length
should not be looked up in every loop of a for
-loopEven memory arrays incur the overhead of bit tests and bit shifts to calculate the array length. Storage array length checks incur an extra Gwarmaccess (100 gas) PER-LOOP.
for (uint i; i < _assets.length; ++i) {
for (uint i; i < _updatedAssets.length; ++i) {
for (uint i; i < _inactiveAssets.length; ++i) {
for (uint i; i < _assets.length; ++i) {
for (uint i; i < _assets.length; ++i) {
for (uint i; i < _inactiveAssets.length; ++i) {
for (uint i; i < _assets.length; ++i) {
++i
/i++
should be unchecked{++i}
/unchecked{++i}
when it is not possible for them to overflow, as is the case when used in for
- and while
-loopsfor (uint i; i < _assets.length; ++i) {
for (uint i; i < assets.length(); ++i) {
for (uint i; i < inactiveAssets.length(); ++i) {
for (uint i; i < length; ++i) {
for (uint i; i < length + inactiveAssets.length(); ++i) {
for (uint i; i < assets.length(); ++i) {
for (uint i; i < _updatedAssets.length; ++i) {
for (uint i; i < _inactiveAssets.length; ++i) {
for (uint i; i < _assets.length; ++i) {
for (uint i; i < _assets.length; ++i) {
for (uint i; i < assets.length(); ++i) {
for (uint _i; _i < diff.assetCount; ++_i) {
for (uint i; i < _inactiveAssets.length; ++i) {
for (uint i; i < assets.length(); ++i) {
for (uint i; i < assets.length(); ++i) {
for (uint i; i < _assets.length; ++i) {
for (uint i = 0; i < path.length - 1; i++) {
for (uint i = 0; i < path.length - 1; i++) {
require()
/revert()
strings longer than 32 bytes cost extra gasrevert("TopNMarketCapIndex: REWEIGH_FAILED");
require(IAccessControl(registry).hasRole(ASSET_ROLE, asset), "TopNMarketCapIndex: INVALID_ASSET");
require(_oracles.length == _path.length - 1, "UniswapV2PathPriceOracle: ORACLES");
return _mint(msg.sender);
return _burn(_recipient);
> 0
costs more gas than != 0
when used on a uint
in a require()
statementrequire(lastAssetBalanceInBase > 0, "Index: INSUFFICIENT_AMOUNT");
require(value > 0, "Index: INSUFFICIENT_AMOUNT");
require(denominator > 0);
require(_assetPerBaseInUQ > 0, "IndexLibrary: ORACLE");
require(shares > 0, "NAV: INSUFFICIENT_AMOUNT");
require(amount > 0, "NAV: INSUFFICIENT_SHARES_BURNED");
for (uint i = 0; i < path.length - 1; i++) {
for (uint i = 0; i < path.length - 1; i++) {
++i
costs less gas than ++i
, especially when it's used in for
-loops (--i
/i--
too)for (uint i = 0; i < path.length - 1; i++) {
for (uint i = 0; i < path.length - 1; i++) {
require()
statements that use &&
saves gasSee this issue for an example
require(_baseAggregator != address(0) && _base != address(0), "ChainlinkPriceOracle: ZERO");
require(basePrice > 0 && quotePrice > 0, "ChainlinkPriceOracle: NEGATIVE");
require( _updatedAssets.length > 1 && _updatedWeights.length == _updatedAssets.length && _updatedAssets.length <= IIndexRegistry(registry).maxComponents(), "ManagedIndex: INVALID" );
require(reserve0 != 0 && reserve1 != 0, "UniswapV2PriceOracle: RESERVES");
uints
/ints
smaller than 32 bytes (256 bits) incurs overheadWhen using elements that are smaller than 32 bytes, your contract’s gas usage may be higher. This is because the EVM operates on 32 bytes at a time. Therefore, if the element is smaller than that, the EVM must use more operations in order to reduce the size of the element from 32 bytes to the desired size.
https://docs.soliditylang.org/en/v0.8.11/internals/layout_in_storage.html Use a larger size then downcast where needed
uint8 answerDecimals;
uint8 decimals;
uint8 private immutable baseDecimals;
uint8 private immutable baseAnswerDecimals;
event UpdateAnatomy(address asset, uint8 weight);
function mintingFeeInBPOf(address _index) external view returns (uint16);
function burningFeeInBPOf(address _index) external view returns (uint16);
function convertToIndex(uint _baseAmount, uint8 _indexDecimals) external view returns (uint);
uint16 constant DECIMAL_FACTOR = 10_000;
uint8 internal constant RESOLUTION = 112;
uint8 constant MAX_WEIGHT = type(uint8).max;
uint8 _weight,
uint8 newWeight = _updatedWeights[i];
uint8 prevWeight = weightOf[asset];
uint8 weight = _weights[i];
uint8 private baseDecimals;
function convertToIndex(uint _baseAmount, uint8 _indexDecimals) external view override returns (uint) {
uint8 public topN;
uint8 _topN,
uint8 _totalWeight;
uint8 weight = _i == 0
uint8 _totalWeight;
uint8 weight;
uint8 totalWeight;
uint8 weight = uint8((_capitalizations[i] * type(uint8).max) / _totalCapitalization);
uint8 totalWeight;
uint8 weight = uint8((_capitalizations[i] * type(uint8).max) / _totalCapitalization);
uint32 private blockTimestampLast;
uint112 reserve0;
uint112 reserve1;
(uint price0Cml, uint price1Cml, uint32 blockTimestamp) = address(_pair).currentCumulativePrices();
uint32 timeElapsed = blockTimestamp - blockTimestampLast;
(uint price0Cumulative, uint price1Cumulative, uint32 blockTimestamp) = address(pair).currentCumulativePrices();
uint32 timeElapsed = blockTimestamp - blockTimestampLast;
keccak256()
, should use immutable
rather than constant
See this issue for a detail description of the issue
bytes32 internal constant INDEX_MANAGER_ROLE = keccak256("INDEX_MANAGER_ROLE");
bytes32 private constant ASSET_MANAGER_ROLE = keccak256("ASSET_MANAGER_ROLE");
bytes32 internal constant ASSET_ROLE = keccak256("ASSET_ROLE");
bytes32 internal constant SKIPPED_ASSET_ROLE = keccak256("SKIPPED_ASSET_ROLE");
bytes32 internal constant ASSET_ROLE = keccak256("ASSET_ROLE");
bytes32 private constant ASSET_MANAGER_ROLE = keccak256("ASSET_MANAGER_ROLE");
bytes32 internal constant ORDERER_ROLE = keccak256("ORDERER_ROLE");
bytes32 internal constant ASSET_ROLE = keccak256("ASSET_ROLE");
bytes32 internal constant ASSET_ROLE = keccak256("ASSET_ROLE");
bytes32 internal constant ORDERER_ROLE = keccak256("ORDERER_ROLE");
bytes32 private constant INDEX_ROLE = keccak256("INDEX_ROLE");
bytes32 private constant ORACLE_ROLE = keccak256("ORACLE_ROLE");
bytes32 private constant ORDERER_ROLE = keccak256("ORDERER_ROLE");
bytes32 private constant RESERVE_MANAGER_ROLE = keccak256("RESERVE_MANAGER_ROLE");
require()
/revert()
checks should be refactored to a modifier or functionrequire(priceOracleOf[_asset] != address(0), "PhuturePriceOracle: UNSET");
require()
or revert()
statements that check input arguments should be at the top of the functionChecks that involve constants should come before checks that involve state variables
require(_asset != address(0), "ChainlinkPriceOracle: ZERO");
require(_base != address(0), "PhuturePriceOracle: ZERO");
require(_asset != address(0), "vToken: ZERO");
revert()
/require()
strings to save deployment gaspayable
If a function modifier such as onlyOwner
is used, the function will revert if a normal user tries to pay the function. Marking the function as payable
will lower the gas cost for legitimate callers because the compiler will not include checks for whether a payment was provided.
function setOracleOf(address _asset, address _oracle) external override onlyRole(ASSET_MANAGER_ROLE) {
function removeOracleOf(address _asset) external override onlyRole(ASSET_MANAGER_ROLE) {
function reweight() external override onlyRole(ORDERER_ROLE) {
function reweight() external override onlyRole(ORDERER_ROLE) {
function transferFrom( address _from, address _to, uint _shares ) external override nonReentrant onlyRole(ORDERER_ROLE) {
function mint() external override nonReentrant onlyRole(INDEX_ROLE) returns (uint shares) {
function burn(address _recipient) external override nonReentrant onlyRole(INDEX_ROLE) returns (uint amount) {
function mintFor(address _recipient) external override nonReentrant onlyRole(ORDERER_ROLE) returns (uint) {
function burnFor(address _recipient) external override nonReentrant onlyRole(ORDERER_ROLE) returns (uint) {
Use a solidity version of at least 0.8.10 to have external calls skip contract existence checks if the external call has a return value
pragma solidity >=0.8.7;
pragma solidity >=0.8.7;
pragma solidity >=0.8.7;
pragma solidity >=0.8.7;
pragma solidity >=0.8.7;
pragma solidity >=0.8.7;
pragma solidity >=0.8.7;
pragma solidity >=0.8.7;
pragma solidity >=0.8.7;
pragma solidity >=0.8.7;
pragma solidity >=0.8.7;
pragma solidity >=0.8.7;
pragma solidity >=0.8.7;
pragma solidity >=0.8.7;
pragma solidity >=0.8.7;
pragma solidity >=0.8.7;
pragma solidity >=0.8.7;
pragma solidity >=0.8.7;
pragma solidity >=0.8.7;
pragma solidity >=0.8.7;
pragma solidity >=0.8.7;
pragma solidity >=0.8.7;
pragma solidity >=0.8.7;
pragma solidity >=0.8.7;
pragma solidity >=0.8.7;
pragma solidity >=0.8.7;
pragma solidity >=0.8.7;
pragma solidity >=0.8.7;
pragma solidity >=0.8.7;
pragma solidity >=0.8.7;
pragma solidity >=0.8.7;
pragma solidity >=0.8.7;
pragma solidity >=0.8.7;
pragma solidity >=0.8.7;
pragma solidity >=0.8.7;
pragma solidity >=0.8.7;
pragma solidity >=0.8.7;
pragma solidity >=0.8.7;
pragma solidity >=0.8.7;
pragma solidity >=0.8.7;
pragma solidity >=0.8.7;
pragma solidity >=0.8.7;
pragma solidity >=0.8.7;
pragma solidity >=0.8.7;
pragma solidity >=0.8.7;
#0 - jn-lp
2022-05-03T17:22:35Z
More than half of the issues were very helpful, thanks!
#1 - moose-code
2022-05-23T14:31:12Z
Very comprehensive, lots of good things here