Summary
When LiquidationPool.sol::distributeAssets fetches the eurUsd price from Chainlink, it misses crucial checks that is recommended by Chainlink to ensure the returned data is fresh and accurate.
Vulnerability Details
According to Chainlink's documentation: https://docs.chain.link/data-feeds/price-feeds/historical-data, the following check is necessary to ensure that the returned price data is fresh and accurate.
The current distributeAssets() function does not check the timestamp the data was last updated, neither does it not implement a check on the round of the returned data: latestRoundData
function distributeAssets(ILiquidationPoolManager.Asset[] memory _assets, uint256 _collateralRate, uint256 _hundredPC) external payable {
consolidatePendingStakes();
@> (,int256 priceEurUsd,,,) = Chainlink.AggregatorV3Interface(eurUsd).latestRoundData(); @audit-issue no stale price check
...
if (asset.amount > 0) {
@> (,int256 assetPriceUsd,,,) = Chainlink.AggregatorV3Interface(asset.token.clAddr).latestRoundData();
...
uint256 costInEuros = _portion * 10 ** (18 - asset.token.dec) * uint256(assetPriceUsd) / uint256(priceEurUsd) * _hundredPC / _collateralRate;
if (costInEuros > _position.EUROs) {
_portion = _portion * _position.EUROs / costInEuros;
costInEuros = _position.EUROs;
}
_position.EUROs -= costInEuros;
rewards[abi.encodePacked(_position.holder, asset.token.symbol)] += _portion;
burnEuros += costInEuros;
if (asset.token.addr == address(0)) {
nativePurchased += _portion;
} else {
IERC20(asset.token.addr).safeTransferFrom(manager, address(this), _portion);
}
}
}
}
positions[holders[j]] = _position;
}
if (burnEuros > 0) IEUROs(EUROs).burn(address(this), burnEuros);
returnUnpurchasedNative(_assets, nativePurchased);
}
Impact
Chainlink may return stale data for both priceEurUsd & assetPriceUsd that becomes accepted by the protocol, either allowing _position.EUROs to deduct less EUROs or more than intended during distribution of assets.
Tools Used
Manual review
Recommendations
Let's add the following checks:
function distributeAssets(ILiquidationPoolManager.Asset[] memory _assets, uint256 _collateralRate, uint256 _hundredPC) external payable {
consolidatePendingStakes();
- (,int256 priceEurUsd,,,) = Chainlink.AggregatorV3Interface(eurUsd).latestRoundData();
+ (uint80 roundId, int256 priceEurUsd, ,uint256 updatedAt,) = Chainlink.AggregatorV3Interface(eurUsd).latestRoundData();
+ if (updatedAt < block.timestamp - maxDelayTime) revert PRICE_OUTDATED();
...
if (asset.amount > 0) {
- (,int256 assetPriceUsd,,,) = Chainlink.AggregatorV3Interface(asset.token.clAddr).latestRoundData();
+ (uint80 roundIdOne, int256 priceEurUsd, ,uint256 updatedAtOne,) = Chainlink.AggregatorV3Interface(asset.token.clAddr).latestRoundData();
+ if (updatedAtOne < block.timestamp - maxDelayTime) revert PRICE_OUTDATED();
...
uint256 costInEuros = _portion * 10 ** (18 - asset.token.dec) * uint256(assetPriceUsd) / uint256(priceEurUsd) * _hundredPC / _collateralRate;
if (costInEuros > _position.EUROs) {
_portion = _portion * _position.EUROs / costInEuros; // usage
costInEuros = _position.EUROs;
}
_position.EUROs -= costInEuros;
rewards[abi.encodePacked(_position.holder, asset.token.symbol)] += _portion;
burnEuros += costInEuros;
if (asset.token.addr == address(0)) {
nativePurchased += _portion;
} else {
IERC20(asset.token.addr).safeTransferFrom(manager, address(this), _portion);
}
}
}
}
positions[holders[j]] = _position;
}
if (burnEuros > 0) IEUROs(EUROs).burn(address(this), burnEuros);
returnUnpurchasedNative(_assets, nativePurchased);
}