Thunder Loan

AI First Flight #7
Beginner FriendlyFoundryDeFiOracle
EXP
View results
Submission Details
Severity: high
Valid

getCalculatedFee Does Not Normalize Token Decimals, Causing Near-Zero Fees for Non-18 Decimal Tokens

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


Updates

Lead Judging Commences

ai-first-flight-judge Lead Judge 37 minutes ago
Submission Judgement Published
Validated
Assigned finding tags:

[H-03] fee are less for non standard ERC20 Token

## 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.

Support

FAQs

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

Give us feedback!