Thunder Loan

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

ThunderLoan::setAllowedToken permanently strands LP funds when a token is disable

Root + Impact

Description

  • setAllowedToken(token, false)executesdelete s_tokenToAssetToken
    [token] — the only pointer ThunderLoan keeps to that token’s AssetToken contract. Every
    underlying token deposited by LPs remains sitting inside that AssetToken, but redeem()
    (and every other entry point) is gated by the revertIfNotAllowedToken(token) modifier,
    i.e. isAllowedToken(token)== (s_tokenToAssetToken[token] != address(0)).
    Once the mapping entry is deleted, isAllowedToken returns false and LPs can no longer call
    redeem() for that token.

// Root cause in the codebase with @> marks to highlight the relevant section

Risk

Likelihood:

  • Re‑enabling the token via setAllowedToken(token, true) does not fix this: it deploys
    a brand new AssetToken (new AssetToken()) with fresh state (totalSupply == 0,
    s_exchangeRate == STARTING_EXCHANGE_RATE) and repoints s_tokenToAssetToken
    [token]atthatnewcontract. TheoldAssetToken—andeveryunderlyingtokenandLPsharebal‑
    ance inside it — is permanently orphaned: AssetToken.mint/burn/transferUnderlyingTo
    are onlyThunderLoan‑gated, so nothing outside ThunderLoan.redeem() can move those
    funds, and ThunderLoan.redeem() can never be routed to the old contract agai

Impact:

  • Impact 1 -
    A single onlyOwner call (deliberate or mistaken) permanently locks 100% of the LP funds
    deposited for that token, with no recovery path — total, irreversible loss of funds for every LP holding
    that token’s AssetToken

Proof of Concept

Add the following test to ThunderLoanTest.t.sol (uses the existing
setAllowedToken/hasDeposits modifiers):
1 + function testDisablingTokenStrandsLPFunds() public setAllowedToken
hasDeposits {
2 + AssetToken oldAsset = thunderLoan.getAssetFromToken(tokenA);
3 + assertEq(tokenA.balanceOf(address(oldAsset)), DEPOSIT_AMOUNT);
4 +
5 + vm.prank(thunderLoan.owner());
6 + thunderLoan.setAllowedToken(tokenA, false);
7 +
8 + // liquidityProvider can no longer redeem the funds sitting in
`oldAsset`
9 + vm.prank(liquidityProvider);
10 + vm.expectRevert(abi.encodeWithSelector(ThunderLoan.
ThunderLoan__NotAllowedToken.selector, address(tokenA)));
11 + thunderLoan.redeem(tokenA, type(uint256).max);
12 +
13 + // re-enabling the token does not restore access: a new, empty
AssetToken is deployed
14 + vm.prank(thunderLoan.owner());
15 + thunderLoan.setAllowedToken(tokenA, true);
16 + assertTrue(address(thunderLoan.getAssetFromToken(tokenA)) !=
address(oldAsset));
17 +
18 + // oldAsset still legitimately holds the LP's funds;
ThunderLoan can never reach them again
19 + assertEq(tokenA.balanceOf(address(oldAsset)), DEPOSIT_AMOUNT);
20 + }

Recommended Mitigation

1. Do not delete the mapping entry on disable. Track an allow‑
listed/paused status separately (e.g. a bool/enum per token) so revertIfNotAllowedToken
can block new deposits/flash loans while redeem() still resolves to the same, still‑referenced
AssetToken. 2. If a token must truly be retired, add an explicit migration/sweep path that lets LPs
(or the owner, transparently) withdraw the old AssetToken’s remaining underlying pro‑rata before the mapping is ever cleared
Updates

Lead Judging Commences

ai-first-flight-judge Lead Judge about 3 hours ago
Submission Judgement Published
Validated
Assigned finding tags:

[M-01] 'ThunderLoan::setAllowedToken' can permanently lock liquidity providers out from redeeming their tokens

## Description If the 'ThunderLoan::setAllowedToken' function is called with the intention of setting an allowed token to false and thus deleting the assetToken to token mapping; nobody would be able to redeem funds of that token in the 'ThunderLoan::redeem' function and thus have them locked away without access. ## Vulnerability Details If the owner sets an allowed token to false, this deletes the mapping of the asset token to that ERC20. If this is done, and a liquidity provider has already deposited ERC20 tokens of that type, then the liquidity provider will not be able to redeem them in the 'ThunderLoan::redeem' function. ```solidity function setAllowedToken(IERC20 token, bool allowed) external onlyOwner returns (AssetToken) { if (allowed) { if (address(s_tokenToAssetToken[token]) != address(0)) { revert ThunderLoan__AlreadyAllowed(); } string memory name = string.concat("ThunderLoan ", IERC20Metadata(address(token)).name()); string memory symbol = string.concat("tl", IERC20Metadata(address(token)).symbol()); AssetToken assetToken = new AssetToken(address(this), token, name, symbol); s_tokenToAssetToken[token] = assetToken; emit AllowedTokenSet(token, assetToken, allowed); return assetToken; } else { AssetToken assetToken = s_tokenToAssetToken[token]; @> delete s_tokenToAssetToken[token]; emit AllowedTokenSet(token, assetToken, allowed); return assetToken; } } ``` ```solidity function redeem( IERC20 token, uint256 amountOfAssetToken ) external revertIfZero(amountOfAssetToken) @> revertIfNotAllowedToken(token) { AssetToken assetToken = s_tokenToAssetToken[token]; uint256 exchangeRate = assetToken.getExchangeRate(); if (amountOfAssetToken == type(uint256).max) { amountOfAssetToken = assetToken.balanceOf(msg.sender); } uint256 amountUnderlying = (amountOfAssetToken * exchangeRate) / assetToken.EXCHANGE_RATE_PRECISION(); emit Redeemed(msg.sender, token, amountOfAssetToken, amountUnderlying); assetToken.burn(msg.sender, amountOfAssetToken); assetToken.transferUnderlyingTo(msg.sender, amountUnderlying); } ``` ## Impact The below test passes with a ThunderLoan\_\_NotAllowedToken error. Proving that a liquidity provider cannot redeem their deposited tokens if the setAllowedToken is set to false, Locking them out of their tokens. ```solidity function testCannotRedeemNonAllowedTokenAfterDepositingToken() public { vm.prank(thunderLoan.owner()); AssetToken assetToken = thunderLoan.setAllowedToken(tokenA, true); tokenA.mint(liquidityProvider, AMOUNT); vm.startPrank(liquidityProvider); tokenA.approve(address(thunderLoan), AMOUNT); thunderLoan.deposit(tokenA, AMOUNT); vm.stopPrank(); vm.prank(thunderLoan.owner()); thunderLoan.setAllowedToken(tokenA, false); vm.expectRevert(abi.encodeWithSelector(ThunderLoan.ThunderLoan__NotAllowedToken.selector, address(tokenA))); vm.startPrank(liquidityProvider); thunderLoan.redeem(tokenA, AMOUNT_LESS); vm.stopPrank(); } ``` ## Recommendations It would be suggested to add a check if that assetToken holds any balance of the ERC20, if so, then you cannot remove the mapping. ```diff function setAllowedToken(IERC20 token, bool allowed) external onlyOwner returns (AssetToken) { if (allowed) { if (address(s_tokenToAssetToken[token]) != address(0)) { revert ThunderLoan__AlreadyAllowed(); } string memory name = string.concat("ThunderLoan ", IERC20Metadata(address(token)).name()); string memory symbol = string.concat("tl", IERC20Metadata(address(token)).symbol()); AssetToken assetToken = new AssetToken(address(this), token, name, symbol); s_tokenToAssetToken[token] = assetToken; emit AllowedTokenSet(token, assetToken, allowed); return assetToken; } else { AssetToken assetToken = s_tokenToAssetToken[token]; + uint256 hasTokenBalance = IERC20(token).balanceOf(address(assetToken)); + if (hasTokenBalance == 0) { delete s_tokenToAssetToken[token]; emit AllowedTokenSet(token, assetToken, allowed); + } return assetToken; } } ```

Support

FAQs

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

Give us feedback!