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:
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);
}
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);
}
}
}
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 () => {
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");
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");
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
);
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();
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);
await LiquidationPoolManager.runLiquidation(
(
await VaultManager.connect(holder3).vaults()
)[0].tokenId
);
await fastForward(DAY);
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]);
}