The Standard

The Standard
DeFiHardhat
20,000 USDC
View results
Submission Details
Severity: medium
Valid

Bad debt is not handled by the protocol and will inflate the EURO token

Summary

As this is a borrowing / lending protocol, bad debt can occur depending on the market conditions of the collateral. The system does not handle bad debt at all and any occurrence of bad debt will inflate the EURO token supply and therefore reducing the buying power of all users in the system. Inflating the EURO token will also increase the real collateral rate as loans are given out in EURO tokens while they are measured EUR which depegs from each other.

Vulnerability Details

The flow of loans in the protocol looks like that:

  • Borrower mints a vault and deposits collateral

  • Borrower mints new EURO tokens (collateral tokens are now locked)

  • Borrower repays the loan and gets back the collateral tokens OR

  • Market Conditions lead to the loan being liquidateable (as the value of the collateral tokens drops)

  • Anyone liquidates the loan

  • All the collateral tokens are bought with the stakers EURO tokens (they are burned), what can not be bought (because too less EUROs are in the pool) is sent to the protocol instead (for free)

Here we can see the liquidation flow. Anyone can call runLiquidation which liquidates the vault calls distributeAssets to sell the collateral tokens for a discount to the stakers and forward the remaining collateral tokens to the protocol for free:

// LiquidationPoolManager (liquidates vault calls distributeAssets and forwardRemainingRewards)
function runLiquidation(uint256 _tokenId) external {
ISmartVaultManager manager = ISmartVaultManager(smartVaultManager);
manager.liquidateVault(_tokenId);
distributeFees();
ITokenManager.Token[] memory tokens = ITokenManager(manager.tokenManager()).getAcceptedTokens();
ILiquidationPoolManager.Asset[] memory assets = new ILiquidationPoolManager.Asset[](tokens.length);
uint256 ethBalance;
for (uint256 i = 0; i < tokens.length; i++) {
ITokenManager.Token memory token = tokens[i];
if (token.addr == address(0)) {
ethBalance = address(this).balance;
if (ethBalance > 0) assets[i] = ILiquidationPoolManager.Asset(token, ethBalance);
} else {
IERC20 ierc20 = IERC20(token.addr);
uint256 erc20balance = ierc20.balanceOf(address(this));
if (erc20balance > 0) {
assets[i] = ILiquidationPoolManager.Asset(token, erc20balance);
ierc20.approve(pool, erc20balance);
}
}
}
LiquidationPool(pool).distributeAssets{value: ethBalance}(assets, manager.collateralRate(), manager.HUNDRED_PC());
forwardRemainingRewards(tokens);
}
// LiquidationPoolManager (sends the collateral tokens the stakers did not pay for to the protocol)
function forwardRemainingRewards(ITokenManager.Token[] memory _tokens) private {
for (uint256 i = 0; i < _tokens.length; i++) {
ITokenManager.Token memory _token = _tokens[i];
if (_token.addr == address(0)) {
uint256 balance = address(this).balance;
if (balance > 0) {
(bool _sent,) = protocol.call{value: balance}("");
require(_sent);
}
} else {
uint256 balance = IERC20(_token.addr).balanceOf(address(this));
if (balance > 0) IERC20(_token.addr).transfer(protocol, balance);
}
}
}
// LiquidationPool (distributes the collateral tokens to the stakers by buying them for a discounted EURO price)
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);
}

Here we can see the calculation how much EUROs from the stakers is needed for the given amount of collateral tokens:

_portion = amt of collateral tokens the staker should receive
asset.token.dec = decimal places of the token (token.decimals())
assetPriceUsd = price of the collateral in USD 1e8 precision from chainlink
priceEurUsd = price of EUR in USD in 1e8 precision from chainlink
_hundredPC = 1e5
_collateralRate = collateralRate of the loan in EURO (how much it is overcollateralized)
costInEuros = _portion * 10 ** (18 - asset.token.dec) * assetPriceUsd / priceEurUsd * _hundredPC / _collateralRate
1. _portion * 10 ** (18 - asset.token.dec) = bring the portion to 1e18 precision
2. get the price of the portion in EUR by multiplying it times the asset price in USD / the EUR price in USD
3. divide through the collateralRate => this results in the tokens cost less than they are worth
=> costInEuros = the cost for the amt of tokens the staker should receive

As we can see, the stakers can only win and not lose in buying power when liquidation happen. And it can happen that not all the EURO tokens are burned, espacially if the stakers do not have enough EUROs in the system to buy the collateral tokens. In this case, the remaining collateral tokens are sent to the protocol and no EURO tokens are burned.

In these cases the EURO token will inflate, here is an example:

  • Stakers have 100 EUROs in the system

  • Borrower mints a vault, deposits collateral and mints 500 EUROs

  • At this moment 500 new EURO tokens exist, but they are backed by collateral

  • Market conditions change and the collateral is worth less than the loan

  • The vault is liquidated and the stakers buy the collateral tokens by burning 100 EUROs and the remaining collateral tokens are sent to the protocol

  • Now 400 EURO tokens are backed by nothing as only 100 were burned and the rest of the backing collateral is sent to the stakers and the protocol → Therefore the EURO token supply inflated by 400 tokens

The following POC can be implemented in the liquidationPoolManager.js test file:

describe("bad_debt_inflates_EURO", async () => {
it("bad_debt_inflates_EURO", async () => {
// add ETH, getNFTMetadataContract and WETH_ADDRESS to the imports from ./common.js
// init smart vault manager instead of the mock version
const Tether = await (
await ethers.getContractFactory("ERC20Mock")
).deploy("Tether", "USDT", 6);
const ClUsdUsd = await (
await ethers.getContractFactory("ChainlinkMock")
).deploy("USD / USD");
await ClUsdUsd.setPrice(1e8);
const EthUsd = await (
await ethers.getContractFactory("ChainlinkMock")
).deploy("ETH/USD"); // $1900
await EthUsd.setPrice(DEFAULT_ETH_USD_PRICE);
const TokenManager = await (
await ethers.getContractFactory("TokenManagerMock")
).deploy(ethers.utils.formatBytes32String("ETH"), EthUsd.address);
await TokenManager.addAcceptedToken(Tether.address, ClUsdUsd.address);
ClEthUsd = await (
await ethers.getContractFactory("ChainlinkMock")
).deploy("ETH / USD");
await ClEthUsd.setPrice(DEFAULT_ETH_USD_PRICE);
ClEurUsd = await (
await ethers.getContractFactory("ChainlinkMock")
).deploy("EUR / USD");
await ClEurUsd.setPrice(DEFAULT_EUR_USD_PRICE);
SmartVaultDeployer = await (
await ethers.getContractFactory("SmartVaultDeployerV3")
).deploy(ETH, ClEurUsd.address);
const SmartVaultIndex = await (
await ethers.getContractFactory("SmartVaultIndex")
).deploy();
SwapRouterMock = await (
await ethers.getContractFactory("SwapRouterMock")
).deploy();
NFTMetadataGenerator = await (await getNFTMetadataContract()).deploy();
VaultManager = await fullyUpgradedSmartVaultManager(
DEFAULT_COLLATERAL_RATE,
PROTOCOL_FEE_RATE,
EUROs.address,
Protocol.address,
LiquidationPoolManager.address,
TokenManager.address,
SmartVaultDeployer.address,
SmartVaultIndex.address,
NFTMetadataGenerator.address,
WETH_ADDRESS,
SwapRouterMock.address
);
await SmartVaultIndex.setVaultManager(VaultManager.address);
await EUROs.grantRole(
await EUROs.DEFAULT_ADMIN_ROLE(),
VaultManager.address
);
const EurUsd = await (
await ethers.getContractFactory("ChainlinkMock")
).deploy("EUR/USD"); // $1.06
await EurUsd.setPrice(DEFAULT_EUR_USD_PRICE);
LiquidationPoolManagerContract = await ethers.getContractFactory(
"LiquidationPoolManager"
);
LiquidationPoolManager = await LiquidationPoolManagerContract.deploy(
TST.address,
EUROs.address,
VaultManager.address,
EurUsd.address,
Protocol.address,
POOL_FEE_PERCENTAGE
);
LiquidationPool = await ethers.getContractAt(
"LiquidationPool",
await LiquidationPoolManager.pool()
);
await EUROs.grantRole(await EUROs.BURNER_ROLE(), LiquidationPool.address);
VaultManager.setLiquidatorAddress(LiquidationPoolManager.address);
await VaultManager.connect(holder3).mint();
const vault = await ethers.getContractAt(
"SmartVaultV3",
(
await VaultManager.connect(holder3).vaults()
)[0].status.vaultAddress
);
// init stakers
const tstStake1 = ethers.utils.parseEther("10000");
const eurosStake1 = ethers.utils.parseEther("10000");
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
);
const tstStake2 = ethers.utils.parseEther("200000");
const eurosStake2 = ethers.utils.parseEther("30000");
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 fastForward(DAY);
const euroSupplyBeforeLoan = await EUROs.totalSupply();
// init loan with bad debt
await Tether.mint(vault.address, ethers.utils.parseEther("1"));
await vault
.connect(holder3)
.mint(holder3.address, ethers.utils.parseEther("10000000"));
await ClUsdUsd.setPrice(1);
await fastForward(DAY);
// run liquidation
await LiquidationPoolManager.runLiquidation(
(
await VaultManager.connect(holder3).vaults()
)[0].tokenId
);
await fastForward(DAY);
// EURO tokens inflated
expect(await EUROs.totalSupply()).to.be.greaterThan(euroSupplyBeforeLoan);
});
});

Impact

The EURO token inflates and losses value, which is a loss for all holders of it and therefore all borrowers and stakers. As the loans are given out in EURO tokens and are measured with the EUR value from the chainlink price feed, the system can become unusable for borrower, because the real collateral rate increases on this inflation as the EURO token depegs from EUR. Depending on the size of the bad debt and therefore the inflation, the real collateral rate becomes so high that borrowing funds from the protocol can not be profitable anymore, and therefore the protocol becomes unusable.

Recommendations

The system must burn EURO tokens to cover the bad debt, or otherwise EURO tokens were printed out of thin air. Therefore, the system should not allow borrowers to borrow tokens which are not backed by stakers having EUROs in the system and not allow stakers to remove EUROs from the system if they are needed to back active loans. If bad debt occurs, burn so many EURO tokens in ratio to collateral that the stakers loose buying power to cover the bad debt.

When these changes are implemented, another vulnerability is exposed in the decreasePosition function of the LiquidationPool.sol contract. This function allows stakers to remove their EURO tokens from the system instantly. Therefore, stakers would be able to front run the call that would lead to bad debt for the stakers and move their tokens out of the system and therefore socialize the bad debt among all other stakers.

Implement a withdrawal queue for stakers to remove their EURO tokens from the system, as in the increasePosition function.

Here we can see the decresasePosition function and that it would allow front running the bad debt call to avoid paying for it:

function decreasePosition(uint256 _tstVal, uint256 _eurosVal) external {
consolidatePendingStakes();
ILiquidationPoolManager(manager).distributeFees();
require(_tstVal <= positions[msg.sender].TST && _eurosVal <= positions[msg.sender].EUROs, "invalid-decr-amount");
if (_tstVal > 0) {
IERC20(TST).safeTransfer(msg.sender, _tstVal);
positions[msg.sender].TST -= _tstVal;
}
if (_eurosVal > 0) {
IERC20(EUROs).safeTransfer(msg.sender, _eurosVal);
positions[msg.sender].EUROs -= _eurosVal;
}
if (empty(positions[msg.sender])) deletePosition(positions[msg.sender]);
}
Updates

Lead Judging Commences

hrishibhat Lead Judge over 1 year ago
Submission Judgement Published
Validated
Assigned finding tags:

Bad-debt

Support

FAQs

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