Description
getCalculatedFee() computes the flash loan fee by multiplying amount by the oracle price and dividing by s_feePrecision (1e18). The amount parameter is the raw
token amount passed by the caller, which is denominated in the token's own decimal precision — not normalized to 18 decimals.
WETH has 18 decimals so 1 WETH = 1e18. USDT has 6 decimals so 2000 USDT = 2000e6 = 2e9. When the same formula divides both by 1e18, the USDT borrower's fee is
computed from a number 1e12 times smaller, even though the borrowed USD value is identical.
// ThunderLoan.sol
function getCalculatedFee(IERC20 token, uint256 amount) public view returns (uint256 fee) {
// @> amount is in the token's native decimals — NOT normalized to 18
// @> for USDT: amount = 2000e6 = 2e9
// @> for WETH: amount = 1e18 (equivalent USD value ~$2000)
uint256 valueOfBorrowedToken = (amount * getPriceInWeth(address(token))) / s_feePrecision;
// @> USDT: (2e9 * 5e14) / 1e18 = 1e6
// @> WETH: (1e18 * 1e18) / 1e18 = 1e18
fee = (valueOfBorrowedToken * s_flashLoanFee) / s_feePrecision;
// @> USDT fee: (1e6 * 3e15) / 1e18 = 3000 wei (~$0.000000000006)
// @> WETH fee: (1e18 * 3e15) / 1e18 = 3e15 wei (~$0.006)
}
Risk
Likelihood:
USDT, USDC, and WBTC are all explicitly listed as compatible tokens in the scope and have 6, 6, and 8 decimals respectively
Any user borrowing these tokens will automatically pay the reduced fee — no special conditions required
Impact:
Borrowers of 6-decimal tokens (USDT/USDC) pay ~1 trillion times less in fees than borrowers of equivalent dollar value in 18-decimal tokens
LPs providing USDT/USDC liquidity earn negligible yield compared to LPs providing WETH, breaking the yield model for non-18 decimal pools
Proof of Concept
function testFeeDecimalMismatch() public {
// Setup: allow tokenA (18 dec mock) and a 6-decimal mock USDT
ERC20MockDecimals usdt = new ERC20MockDecimals(6); // 6-decimal token
mockPoolFactory.createPool(address(usdt));
// Set price: 1 USDT = 0.0005 ETH (5e14 wei) — ~$1 at $2000/ETH
MockTSwapPool(mockPoolFactory.getPool(address(usdt))).setPrice(5e14);
vm.prank(thunderLoan.owner());
thunderLoan.setAllowedToken(IERC20(address(usdt)), true);
// Borrow amounts with equal USD value: 1 WETH ($2000) vs 2000 USDT ($2000)
uint256 wethAmount = 1e18; // 1 WETH, 18 decimals
uint256 usdtAmount = 2000 * 1e6; // 2000 USDT, 6 decimals
uint256 wethFee = thunderLoan.getCalculatedFee(tokenA, wethAmount);
uint256 usdtFee = thunderLoan.getCalculatedFee(IERC20(address(usdt)), usdtAmount);
// wethFee ≈ 3e15 (0.003 ETH)
// usdtFee ≈ 3000 wei (effectively zero)
assertGt(wethFee / usdtFee, 1e12); // WETH borrower pays >1 trillion times more — PASSES
}
Recommended Mitigation
Normalize amount to 18-decimal precision before computing the fee:
function getCalculatedFee(IERC20 token, uint256 amount) public view returns (uint256 fee) {
uint256 decimals = IERC20Metadata(address(token)).decimals();
uint256 normalizedAmount = amount * 10 ** (18 - decimals);
uint256 valueOfBorrowedToken = (amount * getPriceInWeth(address(token))) / s_feePrecision;
uint256 valueOfBorrowedToken = (normalizedAmount * getPriceInWeth(address(token))) / s_feePrecision;
fee = (valueOfBorrowedToken * s_flashLoanFee) / s_feePrecision;
}
Apply the same fix identically to ThunderLoanUpgraded.getCalculatedFee().
## Description Within the functions `ThunderLoan::getCalculatedFee()` and `ThunderLoanUpgraded::getCalculatedFee()`, an issue arises with the calculated fee value when dealing with non-standard ERC20 tokens. Specifically, the calculated value for non-standard tokens appears significantly lower compared to that of standard ERC20 tokens. ## Vulnerability Details //ThunderLoan.sol ```solidity function getCalculatedFee(IERC20 token, uint256 amount) public view returns (uint256 fee) { //slither-disable-next-line divide-before-multiply @> uint256 valueOfBorrowedToken = (amount * getPriceInWeth(address(token))) / s_feePrecision; @> //slither-disable-next-line divide-before-multiply fee = (valueOfBorrowedToken * s_flashLoanFee) / s_feePrecision; } ``` ```solidity //ThunderLoanUpgraded.sol function getCalculatedFee(IERC20 token, uint256 amount) public view returns (uint256 fee) { //slither-disable-next-line divide-before-multiply @> uint256 valueOfBorrowedToken = (amount * getPriceInWeth(address(token))) / FEE_PRECISION; //slither-disable-next-line divide-before-multiply @> fee = (valueOfBorrowedToken * s_flashLoanFee) / FEE_PRECISION; } ``` ## Impact Let's say: - user_1 asks a flashloan for 1 ETH. - user_2 asks a flashloan for 2000 USDT. ```solidity function getCalculatedFee(IERC20 token, uint256 amount) public view returns (uint256 fee) { //1 ETH = 1e18 WEI //2000 USDT = 2 * 1e9 WEI uint256 valueOfBorrowedToken = (amount * getPriceInWeth(address(token))) / s_feePrecision; // valueOfBorrowedToken ETH = 1e18 * 1e18 / 1e18 WEI // valueOfBorrowedToken USDT= 2 * 1e9 * 1e18 / 1e18 WEI fee = (valueOfBorrowedToken * s_flashLoanFee) / s_feePrecision; //fee ETH = 1e18 * 3e15 / 1e18 = 3e15 WEI = 0,003 ETH //fee USDT: 2 * 1e9 * 3e15 / 1e18 = 6e6 WEI = 0,000000000006 ETH } ``` The fee for the user_2 are much lower then user_1 despite they asks a flashloan for the same value (hypotesis 1 ETH = 2000 USDT). ## Recommendations Adjust the precision accordinly with the allowed tokens considering that the non standard ERC20 haven't 18 decimals.
The contest is live. Earn rewards by submitting a finding.
Submissions are being reviewed by our AI judge. Results will be available in a few minutes.
View all submissionsThe contest is complete and the rewards are being distributed.