Thunder Loan

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

setAllowedToken(token, false) Deletes the AssetToken Mapping While LP Funds Are Still Deposited, Permanently Locking Them

escription

setAllowedToken(token, false) unconditionally deletes the mapping from token to its AssetToken contract. The redeem() function requires the token to pass the
revertIfNotAllowedToken modifier, which checks address(s_tokenToAssetToken[token]) != address(0). After the mapping is deleted, this check always reverts —
regardless of how many underlying tokens LP depositors have locked inside.

There is no mechanism to redeem through the old AssetToken directly, and no grace period or rescue function.

// ThunderLoan.sol
function setAllowedToken(IERC20 token, bool allowed) external onlyOwner returns (AssetToken) {
// ...
} else {
AssetToken assetToken = s_tokenToAssetToken[token];
// @> mapping deleted — AssetToken address no longer findable via protocol
delete s_tokenToAssetToken[token];
emit AllowedTokenSet(token, assetToken, allowed);
return assetToken;
}
}

function redeem(
IERC20 token,
uint256 amountOfAssetToken
)
external
revertIfZero(amountOfAssetToken)
// @> this modifier reverts if s_tokenToAssetToken[token] == address(0)
revertIfNotAllowedToken(token)
{
AssetToken assetToken = s_tokenToAssetToken[token];
// @> assetToken is now address(0) — any call here would also revert
// ...
}

Risk

Likelihood:

  • The owner has unconstrained ability to call setAllowedToken(token, false) at any time

  • There is no warning, timelock, or on-chain check preventing deletion while the AssetToken holds a nonzero balance

Impact:

  • All LP depositors for that token permanently lose access to their underlying funds — the AssetToken contract still holds their tokens but the protocol no
    longer routes to it

  • The impact scales with how much liquidity is deposited at the time of the call

Proof of Concept

function testSetAllowedTokenFalseLocksFunds() public {
vm.prank(thunderLoan.owner());
thunderLoan.setAllowedToken(tokenA, true);

  // LP deposits 10e18 tokenA                                                                                                                                  
  tokenA.mint(liquidityProvider, AMOUNT);                                                                                                                      
  vm.startPrank(liquidityProvider);                                                                                                                            
  tokenA.approve(address(thunderLoan), AMOUNT);                                                                                                                
  thunderLoan.deposit(tokenA, AMOUNT);
  vm.stopPrank();                                                                                                                                              
                                                        
  // Owner disables the token (e.g. security incident, token deprecation)                                                                                      
  vm.prank(thunderLoan.owner());
  thunderLoan.setAllowedToken(tokenA, false);                                                                                                                  
                                                        
  // LP attempts to redeem — permanently reverts                                                                                                               
  vm.startPrank(liquidityProvider);
  vm.expectRevert(                                                                                                                                             
      abi.encodeWithSelector(ThunderLoan.ThunderLoan__NotAllowedToken.selector, address(tokenA))
  );                                                                                                                                                           
  thunderLoan.redeem(tokenA, AMOUNT); // REVERTS forever — funds locked
  vm.stopPrank();                                                                                                                                              

}

Recommended Mitigation

Only allow deletion when the AssetToken holds no remaining balance. Otherwise, force the owner to wind down liquidity before disabling the token:

function setAllowedToken(IERC20 token, bool allowed) external onlyOwner returns (AssetToken) {
// ...
} else {
AssetToken assetToken = s_tokenToAssetToken[token];

  •   if (token.balanceOf(address(assetToken)) > 0) {   
    
  •       revert ThunderLoan__TokenHasExistingDeposits();                                                                                                      
    
  •   }                                                                                                                                                        
      delete s_tokenToAssetToken[token];                                                                                                                       
      emit AllowedTokenSet(token, assetToken, allowed);                                                                                                        
      return assetToken;                                
    

    }
    }


Updates

Lead Judging Commences

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