The Standard

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

Users can not remove collateral because of the discrepancy between mint() and burn()

Summary

burn() function in SmartVaultV3 does not change variable "minted" correctly, hence when user wants to leave the protocol completely, even after burning all tokens they have, they can't remove their collateral.

Vulnerability Details

In an ideal scenario (expected scenario) when users burnt all their EUROs, users should be able to leave the protocol with removing all their collateral. This scenario is respected within canRemoveCollateral() function as can be seen below:

function canRemoveCollateral(ITokenManager.Token memory _token, uint256 _amount) private view returns (bool) {
if (minted == 0) return true;
...

So minted == 0 means user burnt all their EURO's and can remove their collateral without checking further details.
But the problem arises from discrepancy between mint() and burn() functions.
While minting, mint fee is added to the "minted" variable as we can see below:

function mint(address _to, uint256 _amount) external onlyOwner ifNotLiquidated {
uint256 fee = _amount * ISmartVaultManagerV3(manager).mintFeeRate() / ISmartVaultManagerV3(manager).HUNDRED_PC();
require(fullyCollateralised(_amount + fee), UNDER_COLL);
minted = minted + _amount + fee;
...

But while burning, this fee amount does not removed back:

function burn(uint256 _amount) external ifMinted(_amount) {
uint256 fee = _amount * ISmartVaultManagerV3(manager).burnFeeRate() / ISmartVaultManagerV3(manager).HUNDRED_PC();
minted = minted - _amount;
...

Hence minted variable does not set back to "0" in case of full burn.
We can see this in action in the following test:

Test Setup:

After npm installation,

1 - run "forge init --force" in the "2023-12-the-standard" folder. (Assuming you have git cloned the repo)

2 - Create remappings.txt in the root folder with following content:

@forge-std/=lib/forge-std/
@openzeppelin/=node_modules/@openzeppelin/
@chainlink/=node_modules/@chainlink/

3 - foundry.toml should look like this:

[profile.default]
src = 'contracts'
out = 'out'
libs = ['node_modules', 'lib']
test = 'test/foundry/'
cache_path = 'cache_forge'

4 - Inside the test folder (where hardhat tests are) create a folder "foundry" and inside that folder create a file "SmartVault.t.sol" and paste the following setup inside:

pragma solidity 0.8.17;
import {Test, console2} from "forge-std/Test.sol";
import {ERC20Mock} from "../../contracts/utils/ERC20Mock.sol";
import {EUROsMock} from "../../contracts/utils/EUROsMock.sol";
import {ChainlinkMock} from "../../contracts/utils/ChainlinkMock.sol";
import {SmartVaultManagerV5} from "../../contracts/SmartVaultManagerV5.sol";
import {SmartVaultV3} from "../../contracts/SmartVaultV3.sol";
import {PriceCalculator} from "../../contracts/utils/PriceCalculator.sol";
import {SmartVaultDeployerV3} from "../../contracts/utils/SmartVaultDeployerV3.sol";
import {TokenManagerMock} from "../../contracts/utils/TokenManagerMock.sol";
import {SmartVaultManager} from "../../contracts/utils/SmartVaultManager.sol";
import {TransparentUpgradeableProxy, ITransparentUpgradeableProxy} from "@openzeppelin/contracts/proxy/transparent/TransparentUpgradeableProxy.sol";
import {ProxyAdmin} from "@openzeppelin/contracts/proxy/transparent/ProxyAdmin.sol";
import {NFTMetadataGenerator} from "../../contracts/utils/nfts/NFTMetadataGenerator.sol";
import {SmartVaultIndex} from "../../contracts/utils/SmartVaultIndex.sol";
import {LiquidationPoolManager} from "../../contracts/LiquidationPoolManager.sol";
import {LiquidationPool} from "../../contracts/LiquidationPool.sol";
import {SwapRouterMock} from "../../contracts/utils/SwapRouterMock.sol";
import {ILiquidationPoolManager} from "../../contracts/interfaces/ILiquidationPoolManager.sol";
import {ITokenManager} from "../../contracts/interfaces/ITokenManager.sol";
import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
contract SmartVaultV3Test is Test{
address public deployer = vm.addr(1);
address public alice = makeAddr("alice");
address public bob = makeAddr("bob");
address public john = makeAddr("john");
address public attacker = makeAddr("attacker");
address public treasury = vm.addr(4);
ERC20Mock public TST;
ERC20Mock public WBTC;
EUROsMock public EURO;
ChainlinkMock public ClEthUsd;
ChainlinkMock public ClEurUsd;
ChainlinkMock public ClWbtcUsd;
SmartVaultManagerV5 public vaultManagerV5;
SmartVaultManagerV5 public vaultManagerV5Instance;
address public vaultAddress;
SmartVaultV3 public vault;
SmartVaultDeployerV3 public vaultDeployer;
string public ETH = "ETH";
bytes32 public ethBytes32;
TokenManagerMock public tokenManager;
SmartVaultManager public vaultManagerFirstImplementation;
TransparentUpgradeableProxy public proxy;
ProxyAdmin public proxyAdmin;
SmartVaultManager public vaultManagerFirstImplementationProxiedInstance;
NFTMetadataGenerator public nftMetadataGenerator;
SmartVaultIndex public smartVaultIndex;
LiquidationPoolManager public liquidationPoolManager;
LiquidationPool public liquidationPool;
SwapRouterMock public swapRouterMock;
function stringToBytes32(string memory source) public pure returns (bytes32 result) {
bytes memory tempEmptyStringTest = bytes(source);
if (tempEmptyStringTest.length == 0) {
return 0x0;
}
assembly {
result := mload(add(source, 32))
}
}
function setUp() public {
vm.startPrank(deployer);
TST = new ERC20Mock("The Standard Token", "TST", 18);
WBTC = new ERC20Mock("Wrapped BTC", "WBTC", 8);
EURO = new EUROsMock();
skip(5 hours);
/// @notice set up chainlink mock price feeds
ClEthUsd = new ChainlinkMock("ETH / USD");
ClEthUsd.setPrice(237538000000);
ClEurUsd = new ChainlinkMock("EUR / USD");
ClEurUsd.setPrice(109586000);
ClWbtcUsd = new ChainlinkMock("WBTC / USD");
ClWbtcUsd.setPrice(4411586000000);
/// @notice deploy vaultManagerV5 proxy contract
vaultManagerFirstImplementation = new SmartVaultManager();
nftMetadataGenerator = new NFTMetadataGenerator();
smartVaultIndex = new SmartVaultIndex();
ethBytes32 = stringToBytes32(ETH);
vaultDeployer = new SmartVaultDeployerV3(ethBytes32, address(ClEurUsd));
tokenManager = new TokenManagerMock(ethBytes32, address(ClEthUsd));
tokenManager.addAcceptedToken(address(WBTC), address(ClWbtcUsd));
bytes memory setUpData = abi.encodeWithSignature(
"initialize(uint256,uint256,address,address,address,address,address,address,address)",
110000,
500, // mintFeeRate and burnFeeRate
address(EURO),
address(0), // protocol
address(0), // liquidator
address(tokenManager),
address(vaultDeployer),
address(smartVaultIndex),
address(nftMetadataGenerator)
);
proxyAdmin = new ProxyAdmin();
proxy = new TransparentUpgradeableProxy(address(vaultManagerFirstImplementation), address(proxyAdmin), setUpData);
vaultManagerFirstImplementationProxiedInstance = SmartVaultManager(address(proxy));
vaultManagerV5 = new SmartVaultManagerV5();
proxyAdmin.upgrade(ITransparentUpgradeableProxy(address(proxy)), address(vaultManagerV5));
vaultManagerV5Instance = SmartVaultManagerV5(address(proxy));
/// @notice deploy liquidationPoolManager and liquidationPool (it is deployed inside liquidationPoolManager)
liquidationPoolManager = new LiquidationPoolManager(address(TST), address(EURO), address(vaultManagerV5Instance), address(ClEurUsd), payable(treasury), 30000 );
liquidationPool = LiquidationPool(liquidationPoolManager.pool());
vaultManagerV5Instance.setProtocolAddress(address(liquidationPoolManager));
vaultManagerV5Instance.setLiquidatorAddress(address(liquidationPoolManager));
vaultManagerV5Instance = SmartVaultManagerV5(address(proxy));
smartVaultIndex.setVaultManager(address(vaultManagerV5Instance));
EURO.grantRole(EURO.DEFAULT_ADMIN_ROLE(), address(vaultManagerV5Instance));
vm.stopPrank();
}

Here comes the test function:

function testCantLeaveProtocol() public {
// deposits
vm.deal(alice , 100e18);
deal(address(WBTC), alice, 10e8);
deal(address(EURO), alice, 10e18);
vm.startPrank(alice);
(address vaultTemp, ) = vaultManagerV5Instance.mint();
SmartVaultV3 vault0 = SmartVaultV3(payable(vaultTemp));
payable(vault0).call{value: 1e18}("");
IERC20(address(WBTC)).transfer(address(vault0), 1e8);
EURO.approve(address(vault0), 100_000e18);
// mint
vault0.mint(alice, 1000e18);
console2.log(EURO.balanceOf(alice));
//burn
vault0.burn(1000e18);
console2.log(EURO.balanceOf(alice));
// withdraws
vault0.removeCollateral(bytes32(bytes(WBTC.symbol())), 1e8, msg.sender);
}

Then run command "forge test --match-test testCantLeaveProtocol"

As we can see alice deposits some amount(unnecessary for our vulnerability), then mint 1000e18 EUROs, then burns these 1000e18 EUROs.
Then finally without any idea of what she would encounter, tries to removeCollateral that she deposited. But wait, what's happening here:

Running 1 test for test/foundry/SmartVault.t.sol:SmartVaultV3Test
[FAIL. Reason: revert: err-under-coll] testCantLeaveProtocol() (gas: 3543437)
Logs:
1010000000000000000000
5000000000000000000
Test result: FAILED. 0 passed; 1 failed; 0 skipped; finished in 4.55ms
Ran 1 test suites: 0 tests passed, 1 failed, 0 skipped (1 total tests)
Failing tests:
Encountered 1 failing test in test/foundry/SmartVault.t.sol:SmartVaultV3Test
[FAIL. Reason: revert: err-under-coll] testCantLeaveProtocol() (gas: 3543437)
Encountered a total of 1 failing tests, 0 tests succeeded

As you can see she can't get her collateral back because minted variable is not reset back to 0.

In order to see that the problem arises from "minted" variable I did go one step further and changed the variable "minted" in SmartVaultV3 to public and printed the result (added following line to test after minting and burning):

console2.log("minted", vault0.minted());

Here is the log afterwards:

Logs:
1010000000000000000000
minted 1005000000000000000000
5000000000000000000
minted 5000000000000000000

Impact

Users can not remove their collateral hence this is a high impact, since this will happen for every user, it is also high likelihood. Hence this is a high severity vulnerability.

Tools Used

Manual Review, Foundry Testing

Recommendations

Considering mint and burn fees are same, if we add "-fee" to the burn function's minted variable changing process, we get rid of the problem (Tried it and minted variable became 0 after burning and test didn't failed).
It should look like this:

function burn(uint256 _amount) external ifMinted(_amount) {
uint256 fee = _amount * ISmartVaultManagerV3(manager).burnFeeRate() / ISmartVaultManagerV3(manager).HUNDRED_PC();
minted = minted - _amount -fee;
...
Updates

Lead Judging Commences

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

fee-loss

kose Submitter
over 1 year ago
kose Submitter
over 1 year ago
hrishibhat Lead Judge
over 1 year ago
kose Submitter
over 1 year ago
hrishibhat Lead Judge over 1 year ago
Submission Judgement Published
Invalidated
Reason: Design choice

Support

FAQs

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