The Standard

The Standard
DeFiHardhat
20,000 USDC
View results
Submission Details
Severity: low
Invalid

Medium amount of holders will lead to DOS of liquidation process

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++) { //@audit 0 <= j < holders.length
Position memory _position = positions[holders[j]];
uint256 _positionStake = stake(_position);
if (_positionStake > 0) {
for (uint256 i = 0; i < _assets.length; i++) { //@audit 0 <= i < _assets.length
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);
// create some funds to be "liquidated"
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);
// Add 130 stakers
let tstStake = ethers.utils.parseEther('10'); // foo
let eurosStake = ethers.utils.parseEther('20'); // foo
for (let i = 0; i < 130; i++)
{
// Get a new wallet
wallet = ethers.Wallet.createRandom();
// add the provider from Hardhat
wallet = wallet.connect(ethers.provider);
// send ETH to the new wallet so it can perform a tx
await holder1.sendTransaction({to: wallet.address, value: ethers.utils.parseEther("1")});
// give some tokens to new wallet so it can increase position in Liquidation Pool
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);
// Last increasePosition() after 1 day to call consolidatePendingStakes() to reset all pendingStakes
// and simulate the fact that the holders have been there for a long time.
// This proves that this is not an edge case when there is a lot of new holders.
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); // This transaction runs out-of-gas
});

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.

Updates

Lead Judging Commences

hrishibhat Lead Judge almost 2 years ago
Submission Judgement Published
Invalidated
Reason: Known issue
Assigned finding tags:

informational/invalid

Mlome Submitter
almost 2 years ago
hrishibhat Lead Judge
almost 2 years ago
hrishibhat Lead Judge over 1 year ago
Submission Judgement Published
Invalidated
Reason: Known issue
Assigned finding tags:

informational/invalid

Support

FAQs

Can't find an answer? Chat with us on Discord, Twitter or Linkedin.