The Standard

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

Amount Burnt Can Be Incorrect When distributeFees() Is Called

Summary

If a fee distribution during liquidation results in one of the holders having more euros than TST and another holder not having enough euros to cover their portion, it can lead to an incorrect calculation of the burn amount.

Vulnerability Details

The function RunLiquidation calls the distributeFees() function. This function distributes the euros coming from the fees paid by the borrowers.

function distributeFees(uint256 _amount) external onlyManager {
uint256 tstTotal = getTstTotal();
if (tstTotal > 0) {
IERC20(EUROs).safeTransferFrom(msg.sender, address(this), _amount);
for (uint256 i = 0; i < holders.length; i++) {
address _holder = holders[i];
positions[_holder].EUROs += _amount * positions[_holder].TST / tstTotal;
}
for (uint256 i = 0; i < pendingStakes.length; i++) {
pendingStakes[i].EUROs += _amount * pendingStakes[i].TST / tstTotal;
}
}
}

Furthermore, the stake function, which is used for calculating the collateral portion to be received and the Euro price, takes the minimum of each holder's position.

function stake(Position memory _position) private pure returns (uint256) {
return _position.TST > _position.EUROs ? _position.EUROs : _position.TST;
}

If a fee distribution during liquidation results in one of the holders having more euros than TST and another holder not having enough euros to cover their portion, it can lead to an incorrect calculation of the burn amount.

For example:

If Bob has a position of 100,000 euros and 100,000 TST, and Alice has a position of 5,000 euros and 100,000 TST. Meanwhile, the LiquidationPoolManager has accumulated 200,000 euros. Subsequently, a vault gets liquidated, and the amount to be paid is 250,000 euros. When the runLiquidation function is called, it will call the distributeFees function, resulting in each of them receiving 50,000 euros because 50% is allocated to the protocol's address. Then, they will share the fees based on their TST positions.
This will give Bob a position of 100,000 TST and 150,000 euros, and Alice a position of 100,000 TST and 55,000 euros.

However, only Bob's TST position will be considered. He will pay 75,000 euros and receive half of the collateral. Since Alice cannot pay 75,000 euros, she will only pay 55,000 euros, and the remaining 20,000 euros that could have been burned will not be. In the end, Bob will have 75,000 euros and 100,000 TST, while Alice will have 100,000 TST and 0 euros. It is not Bob's fault since he cannot predict the movements caused by the distributeFees function.

Impact

Even if there are enough euros in the LiquidationPool, the burn amount will not be high enough to maintain the peg. Additionally, some holders will not receive the collateral they should get.

Proof of Concept

To run this POC you will need to follow few steps in order to add foundry in the hardhat project:
1-run npm i --save-dev @nomicfoundation/hardhat-foundry
2-Add require("@nomicfoundation/hardhat-foundry"); to the top of your hardhat.config.js file.
(if the git repository is not initialized run git init in your terminal before)
3-Run npx hardhat init-foundry in your terminal. This will generate a foundry.toml file based on your Hardhat project's existing configuration, and will install the forge-std library.
After that create a file in the test folder with the extension .sol and copy this code:

// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.17;
import {LiquidationPoolManager} from "../contracts/LiquidationPoolManager.sol";
import {ERC20Mock} from "../contracts/utils/ERC20Mock.sol";
import {EUROsMock} from "../contracts/utils/EUROsMock.sol";
import {ChainlinkMock} from "../contracts/utils/ChainlinkMock.sol";
import {MockSmartVaultManager} from "../contracts/utils/MockSmartVaultManager.sol";
import {LiquidationPool} from "../contracts/LiquidationPool.sol";
import {TokenManagerMock} from "../contracts/utils/TokenManagerMock.sol";
import {Test, console} from "../lib/forge-std/src/Test.sol";
contract POCLiquidation is Test {
bytes32 NATIVE = bytes32(bytes("ETH"));
ChainlinkMock ETHUsd;
MockSmartVaultManager vaultManager;
LiquidationPoolManager poolManager;
EUROsMock euros;
ERC20Mock TST;
ERC20Mock WBTC;
ChainlinkMock EurUsd;
ChainlinkMock BTCUsd;
TokenManagerMock tokenManager;
LiquidationPool pool;
address protocol = makeAddr("protocol");
function setUp() public {
vm.warp(1 days);
TST = new ERC20Mock("The Standard Token", "TST", 18);
ETHUsd = new ChainlinkMock("ETH / USD");
// ETHUsd.setDecimal(18);
ETHUsd.setPrice(2000e8);
tokenManager = new TokenManagerMock(NATIVE, address(ETHUsd));
euros = new EUROsMock();
EurUsd = new ChainlinkMock("EUR/USD");
EurUsd.setPrice(108e6);
WBTC = new ERC20Mock("wrapped BTC", "WBTC", 8);
BTCUsd = new ChainlinkMock("WBTC/USD");
BTCUsd.setPrice(3500000000000);
tokenManager.addAcceptedToken(address(WBTC), address(BTCUsd));
vaultManager = new MockSmartVaultManager(110000, address(tokenManager));
poolManager = new LiquidationPoolManager(
address(TST), address(euros), address(vaultManager), address(EurUsd), payable(protocol), 50000
);
address poolAddress = poolManager.pool();
pool = LiquidationPool(poolAddress);
euros.grantRole(euros.DEFAULT_ADMIN_ROLE(), address(vaultManager));
euros.grantRole(euros.MINTER_ROLE(), address(this));
euros.grantRole(euros.BURNER_ROLE(), address(this));
euros.grantRole(euros.BURNER_ROLE(), address(pool));
}
function test_incorectBurnAmount() external {
//We create two users.
address bob = makeAddr("Bob");
address alice = makeAddr("Alice");
// We mint euros and TST to Bob and Alice
euros.mint(bob, 60_000e18);
TST.mint(bob, 60_000e18);
euros.mint(alice, 60_000e18);
TST.mint(alice, 60_000e18);
//Bob approve and increase his position.
vm.startPrank(bob);
euros.approve(address(pool), 60_000e18);
TST.approve(address(pool), 60_000e18);
pool.increasePosition(60_000e18, 60_000e18);
vm.stopPrank();
//A Huge amount with burn, mint and swap fees accumulated in the LiquidationPoolManager.
euros.mint(address(poolManager), 1_800_000e18);
vm.startPrank(alice);
euros.approve(address(pool), 60_000e18);
TST.approve(address(pool), 60_000e18);
pool.increasePosition(60_000e18, 60_000e18);
vm.stopPrank();
vm.warp(block.timestamp + 1 days + 1);
// the total euro position of bob is 960_000 euros because increasePosition call distributFees.
// A vault will get liquidated and it has 28 WBTC and 3 ETH. which is approximately
WBTC.mint(address(vaultManager), 28e8);
vm.deal(address(vaultManager), 3e18);
//We compute the amount to burn for each asset with the same formula than distributAssets.
(, int256 WbtcPrice,,,) = BTCUsd.latestRoundData();
(, int256 Ethprice,,,) = ETHUsd.latestRoundData();
(, int256 euroPrice,,,) = EurUsd.latestRoundData();
uint256 costInEurosOfBTC =
28e8 * 10 ** (10) * uint256(WbtcPrice) / uint256(euroPrice) * 100000 / vaultManager.collateralRate();
uint256 costInEurosOfETH =
3e18 * uint256(Ethprice) / uint256(euroPrice) * 100000 / vaultManager.collateralRate();
uint256 totalSupplyBefore = euros.totalSupply();
uint256 euroPoolBalance = euros.balanceOf(address(pool));
poolManager.runLiquidation(0);
uint256 totalSupplyAfter = euros.totalSupply();
uint256 AmountBurnt = totalSupplyBefore - totalSupplyAfter;
(LiquidationPool.Position memory bobPosition, LiquidationPool.Reward[] memory bobRewards) = pool.position(bob);
(LiquidationPool.Position memory alicePosition, LiquidationPool.Reward[] memory aliceRewards) =
pool.position(alice);
console.log(bobRewards[1].amount / 1e7);
console.log(aliceRewards[1].amount / 1e7);
console.log(bobRewards[0].amount / 1e17);
console.log(aliceRewards[0].amount / 1e17);
console.log(WBTC.balanceOf(address(pool)) / 1e8);
console.log(address(pool).balance / 1e18);
console.log(bobPosition.EUROs / 1e18);
console.log(alicePosition.EUROs);
console.log(alicePosition.TST / 1e18);
//The protocol has enought euros and it didn't burn it.
assertGt(euroPoolBalance/1e18,(costInEurosOfBTC + costInEurosOfETH) / 1e18);
assertLt(AmountBurnt / 1e18, (costInEurosOfBTC + costInEurosOfETH) / 1e18);
}
}

run the command : forge test --mt test_incorectBurnAmount --via-ir

Tools Used

Echidna

Recommendations

Take into account only Euro position of each holder. by implementing an internal function that sum the euro position of each holder.

Updates

Lead Judging Commences

hrishibhat Lead Judge over 1 year ago
Submission Judgement Published
Invalidated
Reason: Non-acceptable severity
Assigned finding tags:

informational/invalid

Support

FAQs

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