Summary
The LiquidationPool::distributeAssets(...)
function utilizes Chainlink Price Feeds to fetch asset prices for distributing liquidated Assets, yet it lacks a crucial check for stale prices and Aggregator
Vulnerability Details
Within LiquidationPool::distributeAssets(...)
, the price of tokens in USD is calculated using Chainlink Price Feeds. However, there is no implementation of checks to determine whether the returned prices are stale or not. This absence of staleness checks poses a potential risk, as outdated prices may lead to inaccurate calculations and result in losses for both users and the protocol.
function distributeAssets(ILiquidationPoolManager.Asset[] memory _assets, uint256 _collateralRate, uint256 _hundredPC) external payable {
consolidatePendingStakes();
@> (,int256 priceEurUsd,,,) = Chainlink.AggregatorV3Interface(eurUsd).latestRoundData();
uint256 stakeTotal = getStakeTotal();
uint256 burnEuros;
uint256 nativePurchased;
for (uint256 j = 0; j < holders.length; j++) {
Position memory _position = positions[holders[j]];
uint256 _positionStake = stake(_position);
if (_positionStake > 0) {
for (uint256 i = 0; i < _assets.length; i++) {
ILiquidationPoolManager.Asset memory asset = _assets[i];
if (asset.amount > 0) {
@> (,int256 assetPriceUsd,,,) = Chainlink.AggregatorV3Interface(asset.token.clAddr).latestRoundData();
uint256 _portion = asset.amount * _positionStake / stakeTotal;
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);
}
Github: [205-241]
Additionally, Chainlink aggregators have a built in circuit breaker if the price of an asset goes outside of a predetermined price band. The result is that if an asset experiences a huge drop in value (i.e. LUNA crash) the price of the oracle will continue to return the minPrice
instead of the actual price of the asset. This would allow user to continue buying the asset but at the wrong price. This is exactly what happened to Venus on BSC when LUNA imploded.
ChainlinkFeedRegistry::latestRoundData(...)
pulls the associated aggregator and requests round data from it. ChainlinkAggregators
have minPrice
and maxPrice
circuit breakers built into them. This means that if the price of the asset drops below the minPrice
, the protocol will continue to value the token at minPrice
instead of it's actual value. This will allow user to buy the Liquidated Assets at inflated price.
Example: TokenA
has a minPrice
of $1
. The price of TokenA
drops to $0.10
. The aggregator still returns $1
allowing the user to buy TokenA
as if it is $1
which is 10x it's actual value.
Impact
The presence of stale prices poses a risk of inaccurate asset valuations, potentially resulting in financial losses for both users and the protocol.
Tools Used
Recommendations
It is recommended to add the neccessary checks for Chainlink price Oracles.
function distributeAssets(ILiquidationPoolManager.Asset[] memory _assets, uint256 _collateralRate, uint256 _hundredPC) external payable {
consolidatePendingStakes();
- (,int256 priceEurUsd,,,) = Chainlink.AggregatorV3Interface(eurUsd).latestRoundData();
+ int256 priceEurUsd = getAssetPriceInUsd(eurUsd);
uint256 stakeTotal = getStakeTotal();
uint256 burnEuros;
uint256 nativePurchased;
for (uint256 j = 0; j < holders.length; j++) {
Position memory _position = positions[holders[j]];
uint256 _positionStake = stake(_position);
if (_positionStake > 0) {
for (uint256 i = 0; i < _assets.length; i++) {
ILiquidationPoolManager.Asset memory asset = _assets[i];
if (asset.amount > 0) {
- (,int256 assetPriceUsd,,,) = Chainlink.AggregatorV3Interface(asset.token.clAddr).latestRoundData();
+ assetPriceUsd = getAssetPriceInUsd(asset.token.clAddr);
uint256 _portion = asset.amount * _positionStake / stakeTotal;
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);
}
// for example
+ uint256 validPeriod = 2 hours;
+ mapping(address priceFeed => int256 minPrice) minAnswerForAssetFeed;
+ mapping(address priceFeed => int256 maxPrice) maxAnswerForAssetFeed;
+ function getAssetPriceInUsd(address chainlinkPriceFeed ) public {
+ (uint80 roundId, int256 answer, , uint256 updatedAt, uint80 answeredInRound) =Chainlink.AggregatorV3Interface(chainlinkPriceFeed).latestRoundData();
+ require(block.timestamp - updatedAt < validPeriod, "freshness check failed.")
+ require(answer > minAnswerForAssetFeed(chainlinkPriceFeed), "Min price exceeded");
+ require(answer < maxAnswerForAssetFeed(chainlinkPriceFeed), "Max price exceeded");
+ require(answeredInRound >= roundID, "Stale price");
+ return answer;
+ }