Summary
Liquidation Pool is used to collect and share the funds coming from vault liquidations among the holders. To share the rewards, the distributeAssets() function loops through all the holders. Thus, when the number of holders in the pool is higher than a certain value, the transaction will run out of gas during the execution of the function.
Note that this issue is known by the developers but a proof of concept shows that the issue will arise for a relatively small amount of holders (~130) which is an easily reachable amount for a staking pool.
Vulnerability Details
The LiquidationPool::distributeAssets() function is called when a vault liquidation occurs. It iterates over the array of holders and the array of accepted tokens in a nested loop:
LiquidationPool.sol#L205-L241
File: LiquidationPool.sol
L205: 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);
}
The code goes through holders.length * _assets.length iterations, which can become a really large number in a real world scenario with 5 assets (ETH, WBTC, ARB, LINK, PAXG).
Heuristic tests show that with 3 assets, 130 holders is sufficient for the transaction to exceed the maximum gas amount, blocking all liquidations.
Proof Of Concept
Here is a Hardhat test you can add to test/liquidationPoolManager.js in describe('runLiquidation'... to demonstrate the bug:
Note that to run the test you should increase the mocha timeout (in hardhat.config.js):
module.exports = {
...
mocha: {
timeout: 100000000
},
...
}
it('liquidated assets distribution fails if there is a lot of holders', async () => {
const ethCollateral = ethers.utils.parseEther('0.5');
const wbtcCollateral = BigNumber.from(1_000_000);
const usdcCollateral = BigNumber.from(500_000_000);
await holder5.sendTransaction({to: SmartVaultManager.address, value: ethCollateral});
await WBTC.mint(SmartVaultManager.address, wbtcCollateral);
await USDC.mint(SmartVaultManager.address, usdcCollateral);
const tstStake1 = ethers.utils.parseEther('1000');
const eurosStake1 = ethers.utils.parseEther('2000');
await TST.mint(holder1.address, tstStake1);
await EUROs.mint(holder1.address, eurosStake1);
await TST.connect(holder1).approve(LiquidationPool.address, tstStake1);
await EUROs.connect(holder1).approve(LiquidationPool.address, eurosStake1);
await LiquidationPool.connect(holder1).increasePosition(tstStake1, eurosStake1);
let tstStake = ethers.utils.parseEther('10');
let eurosStake = ethers.utils.parseEther('20');
for (let i = 0; i < 130; i++)
{
wallet = ethers.Wallet.createRandom();
wallet = wallet.connect(ethers.provider);
await holder1.sendTransaction({to: wallet.address, value: ethers.utils.parseEther("1")});
await TST.mint(wallet.address, tstStake);
await EUROs.mint(wallet.address, eurosStake);
await TST.connect(wallet).approve(LiquidationPool.address, tstStake);
await EUROs.connect(wallet).approve(LiquidationPool.address, eurosStake);
increase = LiquidationPool.connect(wallet).increasePosition(1, 1);
await expect(increase).not.to.be.reverted;
}
await fastForward(DAY);
const tstStake2 = ethers.utils.parseEther('4000');
const eurosStake2 = ethers.utils.parseEther('3000');
await TST.mint(holder2.address, tstStake2);
await EUROs.mint(holder2.address, eurosStake2);
await TST.connect(holder2).approve(LiquidationPool.address, tstStake2);
await EUROs.connect(holder2).approve(LiquidationPool.address, eurosStake2);
await LiquidationPool.connect(holder2).increasePosition(tstStake2, eurosStake2);
await LiquidationPoolManager.runLiquidation(TOKEN_ID);
});
Impact
A medium amount of holders in the Liquidation Pool (<130 in a real world scenarion with 5 accepted tokens) will lead to failed executions of liquidation. This will prevent the proper distribution of assets and rewards to holders, affecting the overall functionality of the Liquidation Pool and the block the vault liquidations.
Tools Used
Manual review + Hardhat
Recommendations
One way to avoid the gas exhaustion issue is to refactor the code to distribute assets over multiple transactions. This approach involves breaking down the distribution process into smaller transactions, each handling a subset of holders.