Thunder Loan

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

setAllowedToken(token, false) deletes AssetToken mapping causing revertIfNotAllowedToken to permanently block LP withdrawals

Root + Impact

Description

  • ThunderLoan allows the owner to enable or disable tokens via setAllowedToken. Disabling a token is intended to stop new flash loans and deposits for that token.

  • When a token is disabled, setAllowedToken deletes the entry from s_tokenToAssetToken. After deletion, isAllowedToken(token) returns false, and the revertIfNotAllowedToken modifier — applied to redeem() — blocks all LP withdrawals for that token. LPs holding AssetTokens have no way to recover their underlying deposits.

function setAllowedToken(IERC20 token, bool allowed) external onlyOwner returns (AssetToken) {
if (allowed) {
// ... create AssetToken ...
} else {
AssetToken assetToken = s_tokenToAssetToken[token];
@> delete s_tokenToAssetToken[token]; // removes the mapping — isAllowedToken now returns false
emit AllowedTokenSet(token, assetToken, allowed);
return assetToken;
}
}
// redeem() — blocked after token is disabled:
function redeem(IERC20 token, uint256 amountOfAssetToken)
external
revertIfZero(amountOfAssetToken)
@> revertIfNotAllowedToken(token) // reverts — token is no longer "allowed"
{
...
}
modifier revertIfNotAllowedToken(IERC20 token) {
@> if (!isAllowedToken(token)) {
revert ThunderLoan__NotAllowedToken(token);
}
_;
}

Risk

Likelihood:

  • Any owner action to disable a token — for security, regulatory, or operational reasons — immediately and irrevocably locks all LP deposits for that token.

  • There is no grace period, no warning, and no migration path documented in the contract.

Impact:

  • All LP deposits for the disallowed token are permanently locked. LPs holding AssetTokens have no on-chain path to recover their underlying tokens.

  • A single owner transaction can destroy all LP capital for any token, with no recourse and no reversibility (re-enabling the token creates a new AssetToken contract, not the old one).

Proof of Concept

Static analysis is sufficient for this finding. setAllowedToken(token, false) deletes s_tokenToAssetToken[token] while redeem() reads from the same mapping without a null check. This is verifiable by grep:

grep -n "s_tokenToAssetToken\|setAllowedToken\|redeem" src/protocol/ThunderLoan.sol

Expected output shows the delete in setAllowedToken and the unchecked read in redeem, confirming that disabling a token permanently breaks withdrawals for existing LPs.

Recommended Mitigation

Require that the AssetToken total supply is zero (no outstanding LP shares) before a token can be disabled, or add an emergency withdrawal path that remains available even after a token is de-listed.

+ // Option 1: require zero LP supply before disabling
function setAllowedToken(IERC20 token, bool allowed) external onlyOwner returns (AssetToken) {
if (!allowed) {
AssetToken assetToken = s_tokenToAssetToken[token];
+ require(assetToken.totalSupply() == 0, "LPs must withdraw first");
delete s_tokenToAssetToken[token];
}
}
+ // Option 2: track AssetToken address separately from allowed status
+ mapping(IERC20 => AssetToken) public s_tokenToAssetToken; // never deleted
+ mapping(IERC20 => bool) public s_allowedTokens; // separate flag
// redeem() checks s_tokenToAssetToken[token] != address(0) instead of isAllowedToken
Updates

Lead Judging Commences

ai-first-flight-judge Lead Judge about 7 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!