Thunder Loan

AI First Flight #7
Beginner FriendlyFoundryDeFiOracle
EXP
View results
Submission Details
Impact: high
Likelihood: high
Invalid

Deposit in thunderLoan doesn't manage non 18 decimals token like USDC and USDT

Root + Impact

Description

  • The ThunderLoan protocol allows liquidity providers to deposit ERC20 tokens via deposit() in exchange for AssetTokens (LP tokens). The mintAmount is calculated as (amount * EXCHANGE_RATE_PRECISION) / exchangeRate, which at a 1:1 rate simply equals the raw amount passed in. The AssetToken is a standard ERC20 with 18 decimals.

  • The deposit() function does not normalize the deposited amount to account for the difference in decimals between the underlying token and the AssetToken (always 18 decimals). When a token with fewer decimals (e.g. USDC/USDT with 6 decimals) is deposited, the user receives a drastically incorrect amount of AssetTokens. For example, depositing 1000 USDC (1e9 raw) mints only 1e9 raw AssetTokens, which equals 0.000000001 AssetTokens in human-readable terms instead of the expected 1000. This is an error factor of 10121012.

function deposit(IERC20 token, uint256 amount) external revertIfZero(amount) revertIfNotAllowedToken(token) {
AssetToken assetToken = s_tokenToAssetToken[token];
uint256 exchangeRate = assetToken.getExchangeRate();
@>uint256 mintAmount = (amount * assetToken.EXCHANGE_RATE_PRECISION()) / exchangeRate;
emit Deposit(msg.sender, token, amount);
assetToken.mint(msg.sender, mintAmount);
uint256 calculatedFee = getCalculatedFee(token, amount);
assetToken.updateExchangeRate(calculatedFee);
token.safeTransferFrom(msg.sender, address(assetToken), amount);
}

Risk

Likelihood:

  • When USDT,USDC or ZIL are allowed, they have not 18 decimals

  • Every single deposit of a non-18-decimal token triggers this bug — there is no edge case or special condition required, it happens on the standard execution path.

Impact:

  • Liquidity providers depositing tokens with fewer than 18 decimals receive a negligible amount of AssetTokens (off by a factor of 10(18−tokenDecimals)10(18−tokenDecimals)), resulting in a near-total loss of their deposited funds upon redeem().

  • the fee (calculated on the raw amount) is added to a tiny totalSupply, causing the exchange rate to spike to absurd values, corrupting the accounting for all subsequent depositors, redeemers, and flash loan borrowers of that token.

Proof of Concept

The test deploys ThunderLoan with a 6-decimal ERC20 token (simulating USDC/USDT) and has a user deposit 1000 tokens. It then verifies three things:

  1. The protocol mints only 1e9 raw AssetTokens (equal to the raw deposit amount) instead of the correct 1000e18.

  2. The actual minted amount does not match the expected normalized value of 1000e18.

  3. The error factor between expected and actual is exactly 1e12 (1018−61018−6), proving the protocol fails to normalize for the decimal difference.

This demonstrates that a liquidity provider depositing 1000 USDC effectively receives 0.000000001 AssetTokens instead of 1000, a near-total loss of value representation.

contract POCtest is Test {
ThunderLoan thunderLoanImplementation;
MockPoolFactory mockPoolFactory;
ERC1967Proxy proxy;
ThunderLoan thunderLoan;
ERC20Mock weth;
ERC20Mock tokenA;
ERC20Mock tokenB;
ERC20Mock6Decimals tokenWith6Decimals;
AssetToken assetToken6Decimals;
AssetToken assetTokenA;
AssetToken assetTokenB;
address depositer = makeAddr("depositer");
address flahLoanReceiver = makeAddr("flashLoanReceiver");
address flashLoanAttacker = makeAddr("flashLoanAttacker");
function setUp() public virtual {
thunderLoan = new ThunderLoan();
mockPoolFactory = new MockPoolFactory();
weth = new ERC20Mock();
tokenA = new ERC20Mock();
tokenB = new ERC20Mock();
tokenWith6Decimals = new ERC20Mock6Decimals();
mockPoolFactory.createPool(address(tokenA));
mockPoolFactory.createPool(address(tokenWith6Decimals));
proxy = new ERC1967Proxy(address(thunderLoan), "");
thunderLoan = ThunderLoan(address(proxy));
thunderLoan.initialize(address(mockPoolFactory));
assetTokenB = thunderLoan.setAllowedToken(
IERC20(address(tokenB)),
true
);
assetToken6Decimals = thunderLoan.setAllowedToken(
IERC20(address(tokenWith6Decimals)),
true
);
tokenA.mint(depositer, 50000 * 10 ** tokenA.decimals()); //fund depositer with tokenA
//fund depositer with token with 6 decimals
tokenWith6Decimals.mint(
depositer,
5000 * 10 ** tokenWith6Decimals.decimals()
); // 5000 tokens in 6-decimal representation
}
function test6DecimalsTokenDepositBreaksAccountability() public {
//fund depositer with token with 6 decimals
tokenWith6Decimals.mint(
depositer,
5000 * 10 ** tokenWith6Decimals.decimals()
); // 5000 tokens in 6-decimal representation
uint256 depositAmountHuman = 1000; // user wants to deposit 1000 tokens
uint256 depositAmountRaw = depositAmountHuman *
10 ** tokenWith6Decimals.decimals(); // 1000 * 1e6 = 1e9
// The expected AssetToken amount if decimals were properly normalized:
// Since exchange rate is 1:1 and AssetToken has 18 decimals,
// depositing 1000 tokens should yield 1000 AssetTokens = 1000e18 raw
uint256 expectedAssetTokenRaw = depositAmountHuman *
10 ** assetToken.decimals(); // 1000e18
// Deposit 1000 tokens (6 decimals)
vm.startPrank(depositer);
tokenWith6Decimals.approve(address(thunderLoan), depositAmountRaw);
thunderLoan.deposit(
IERC20(address(tokenWith6Decimals)),
depositAmountRaw
);
vm.stopPrank();
uint256 actualAssetTokenRaw = assetToken.balanceOf(depositer);
// BUG: deposit() calculates mintAmount = (amount * 1e18) / exchangeRate
// With a 6-decimal token: mintAmount = (1e9 * 1e18) / 1e18 = 1e9 raw AssetToken
// But AssetToken has 18 decimals, so 1e9 raw = 0.000000001 AssetToken
// The user deposited 1000 tokens but received mass less AssetTokens
// The protocol mints depositAmountRaw (1e9) instead of 1000e18
assertEq(
actualAssetTokenRaw,
depositAmountRaw,
"Protocol mints raw deposit amount without normalizing decimals"
);
// The minted amount is NOT equal to the correct expected value
assertTrue(
actualAssetTokenRaw != expectedAssetTokenRaw,
"Minted amount should NOT match the correctly normalized value"
);
// The error factor is 1e12 (= 10^(18-6))
assertEq(
expectedAssetTokenRaw / actualAssetTokenRaw,
1e12,
"Error factor is 1e12 due to decimal mismatch"
);
}}
Contract ERC20Mock6Decimals is ERC20Mock {
uint8 private immutable i_decimals;
constructor() ERC20Mock() {
i_decimals = 6;
}
function decimals() public view override returns (uint8) {
return i_decimals;
}
}

Recommended Mitigation

1- Remove USDC,USDT and ZIL from possible allowed tokens

2- Implement a function that normalize decimals before any accounting

function deposit(IERC20 token, uint256 amount) external revertIfZero(amount) revertIfNotAllowedToken(token) {
AssetToken assetToken = s_tokenToAssetToken[token];
uint256 exchangeRate = assetToken.getExchangeRate();
+ uint256 amountNormalized = _normalizeDecimals(amount, IERC20Metadata(address(token)).decimals());
uint256 mintAmount = (amountNormalized * assetToken.EXCHANGE_RATE_PRECISION()) / exchangeRate;
emit Deposit(msg.sender, token, amount);
assetToken.mint(msg.sender, mintAmount);
uint256 calculatedFee = getCalculatedFee(token, amountNormalized);
assetToken.updateExchangeRate(calculatedFee);
token.safeTransferFrom(msg.sender, address(assetToken), amount);
}
+ function _normalizeDecimals(uint256 amount, uint8 tokenDecimals) internal pure returns (uint256) {
+ if (tokenDecimals < 18) {
+ return amount * (10 ** (18 - tokenDecimals));
+ } else if (tokenDecimals > 18) {
+ revert("Token decimals greater than 18 not supported");
+ } else {
+ return amount;
+ }
+ }
Updates

Lead Judging Commences

ai-first-flight-judge Lead Judge about 3 hours ago
Submission Judgement Published
Invalidated
Reason: Incorrect statement

Support

FAQs

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

Give us feedback!