Thunder Loan

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

[M-1] `setAllowedToken` removing a token can permanently trap LP deposits

Root + Impact

The owner can remove a token from the protocol unilaterally without checking for active deposits, preventing liquidity providers from recovering their funds and pending rewards.

Description

The owner can add or remove tokens from the protocol.

However, a token can be removed without any check on whether deposits exist for that token. Users holding LP tokens associated with the removed token cannot recover their deposits or pending rewards, leaving funds permanently trapped in the protocol.

// ThunderLoan.sol
function setAllowedToken(IERC20 token, bool allowed) external onlyOwner {
} else {
AssetToken assetToken = s_tokenToAssetToken[token];
@> delete s_tokenToAssetToken[token]; // removes the mapping
emit AllowedTokenSet(token, assetToken, allowed);
return assetToken;
}
}
// redeem() blocks LPs after removal
function redeem(IERC20 token, uint256 amountOfAssetToken)
external
revertIfZero(amountOfAssetToken)
@> revertIfNotAllowedToken(token) // reverts if token is not in the mapping
{
// ...
}

Risk

Likelihood: Medium

  • Occurs when the owner decides to remove a token from the protocol.

Impact: Medium

  • Liquidity providers cannot withdraw their deposits or rewards, leaving funds permanently trapped.

Proof of Concept

function test_Token_Eliminated() public {
usdc = new ERC20Mock();
address alice = makeAddr("alice");
usdc.mint(alice, 100e18);
ThunderLoan aProxy = ThunderLoan(address(proxy));
AssetToken assetUsdc = aProxy.setAllowedToken(IERC20(address(usdc)), true);
assertTrue(aProxy.isAllowedToken(IERC20(address(usdc))));
vm.startPrank(alice);
usdc.approve(address(aProxy), 100e18);
aProxy.deposit(IERC20(address(usdc)), 100e18);
vm.stopPrank();
uint256 assetTokenBalance = assetUsdc.balanceOf(alice);
assertTrue(assetTokenBalance > 0);
aProxy.setAllowedToken(IERC20(address(usdc)), false);
assertFalse(aProxy.isAllowedToken(IERC20(address(usdc))));
vm.prank(alice);
vm.expectRevert(abi.encodeWithSelector(ThunderLoan.ThunderLoan__NotAllowedToken.selector, IERC20(address(usdc))));
aProxy.redeem(IERC20(address(usdc)), assetTokenBalance);
}

Recommended Mitigation

Check for active deposits before allowing token removal. The AssetToken total supply represents outstanding LP positions.

+ error ThunderLoan__ThereAreActiveDeposits();
function setAllowedToken(IERC20 token, bool allowed) external onlyOwner returns (AssetToken) {
// ...
} else {
AssetToken assetToken = s_tokenToAssetToken[token];
+ if (assetToken.totalSupply() > 0) revert ThunderLoan__ThereAreActiveDeposits();
delete s_tokenToAssetToken[token];
emit AllowedTokenSet(token, assetToken, allowed);
return assetToken;
}
}
Updates

Lead Judging Commences

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