The Standard

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

Dust collateral balances can be trapped in Smart Vault V3

Summary

When a user burns their EUROs tokens, a partial amount of fee is transferred to the protocol from the user, in addition to the tokens being burnt. This can lead to the vault in having no tokens minted to the user directly, but still have dust collateral left behind in the vault, which cannot be claimed as the user has no EUROs token left anymore, and because the vault is not in liquidatable state.

Vulnerability Details

In the funcion SmartVaultV3::burn(), a user can burn their EUROs token. Lets say if a user has X tokens, and if they want to burn Y tokens, then a percentage of fee is transferred to the protocol from the user in terms of EUROs. Lets say the fee is Z, then the condition X = Y + Z needs to be satisfied.

Now, lets see how the complete mint() and burn() functions do the accounting:

  • User puts in some native collateral in the SmartVaultV3. Lets say 1 ETH.

  • User mints 10 tokens. A minting fee of lets say 2 tokens is incurred. Now, the total amount of tokens minted is 10 + 2 = 12 tokens, and this is what gets accounted for in SmartVaultV3::minted state variable. The user gets minted 10 tokens, and the protocol gets minted 2 tokens.

  • Now, lets say the user burns 8 tokens, with a burning fee of 2 tokens. Now, the total amount of minted tokens gets updated to (12 tokens - 8 tokens = 4 tokens). 8 tokens from the user is burnt, and 2 tokens from the user is transferred to the protocol. Now the user has 0 tokens, and the protocol has 4 tokens (2 fee from mint, and 2 fee from burn). These 4 minted tokens are accounted for by the value of SmartVaultV3::minted state variable which is 4.

  • Now, the user tries to take out most of their collateral using the SmartVaultV3::removeCollateralNative() function. They can take the collateral upto the point where the right collaterization is maintained for the 4 tokens that are still accounted for. Lets say the user was able to withdraw 0.99 ETH.

After all the above steps, no one can withdraw the remaining 0.01 ETH, as the user (the owner) was stopped from withdrawing more collateral to maintain collaterization. And because the vault is maintaining the required collaterization, the vault cannot be liquidated. And the protocol has no special previlege function to pull out these dust. So this remaining collateral is stuck in the contract, until the collaterization is somehow possible if the price of collateral tokens dip.

Proof of Concept

Drop the below test in test/smartVault.js

(The test is using describe.only, so when you run the test, then only this test will be run)

describe.only('[PoC] Dust left behind in Smart Vault', async () => {
it('[PoC] Dust left behind in Smart Vault', async () => {
await EUROs.connect(user).approve(Vault.address, ethers.utils.parseEther('10000'));
const collateral = ethers.utils.parseEther('1');
await user.sendTransaction({ to: Vault.address, value: collateral });
const mintedValue = ethers.utils.parseEther('1');
await Vault.connect(user).mint(user.address, mintedValue);
// Try to burn maximum amount of EUROs
const maxBurnAmount = (await EUROs.balanceOf(user.address)).mul(HUNDRED_PC).div(HUNDRED_PC.add(PROTOCOL_FEE_RATE)).add(1);
await Vault.connect(user).burn(maxBurnAmount);
// User has no EUROs anymore
expect((await EUROs.balanceOf(user.address))).to.be.equal(0);
// Brute force removal of as much collateral as possible
let amountToRemoveAtOneTime = ethers.utils.parseEther('1');
while (true) {
try {
await Vault.connect(user).removeCollateralNative(amountToRemoveAtOneTime, user.address);
} catch (e) {
amountToRemoveAtOneTime = amountToRemoveAtOneTime.div(10);
if (amountToRemoveAtOneTime == 0) {
break;
}
}
}
// User got most of their collateral back
expect((await ethers.provider.getBalance(user.address))).to.be.at.least(ethers.utils.parseEther('0.999'));
// Some dust remains locked in the Vault that cannot be withdrawn
expect((await ethers.provider.getBalance(Vault.address))).to.be.at.least(ethers.utils.parseEther('0.0000079'));
// Cannot liquidate the vault proactively either
const tryLiquidating = VaultManager.connect(protocol).liquidateVault(1);
expect(tryLiquidating).to.be.revertedWith('vault-not-undercollateralised');
});
});

Tools Used

Manual Review

Recommendations

There are a couple of options:

  • Have some mechanism to allow Vault owners to mark the vault as "deprecated", and allow the protocol to sweep the deprecated vaults.

  • Allow the protocol to remove collateral for their share of tokens from a given vault.

  • Account for the fee transferred from the user to the protocol in SmartVaultV3::minted state variable, and send part of the collateral to the protocol. The user can specify how much maximum collateral they are willing to transfer to the protocol.

Updates

Lead Judging Commences

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

fee-loss

hrishibhat Lead Judge over 1 year ago
Submission Judgement Published
Invalidated
Reason: Design choice
Assigned finding tags:

fee-loss

Support

FAQs

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