Thunder Loan

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

Delisting a token via setAllowedToken(token, false) deletes the only reference to its AssetToken, so redeem() reverts for every LP and all principal and fees stay frozen until the owner deploys a custom upgrade

Root + Impact

Description

  • The owner lists and delists tokens with setAllowedToken, and liquidity providers exchange their AssetTokens for the underlying plus accrued fees through redeem(). Delisting a token should stop new use of that token without taking away existing LPs' ability to withdraw what they deposited.

  • The delist branch of setAllowedToken only deletes s_tokenToAssetToken[token]. It moves no funds and keeps no record of the old AssetToken. redeem() is gated by revertIfNotAllowedToken, which reads that same mapping, and it also finds the AssetToken through that mapping, so after a delist every redeem reverts and the underlying stays in an AssetToken whose transferUnderlyingTo only ThunderLoan may call. Re-listing deploys a new AssetToken with no link to the old one, so it does not restore access.

// src/protocol/ThunderLoan.sol L121-L126
modifier revertIfNotAllowedToken(IERC20 token) {
if (!isAllowedToken(token)) {
revert ThunderLoan__NotAllowedToken(token);
}
_;
}
​
// src/protocol/ThunderLoan.sol L161-L169
function redeem(
IERC20 token,
uint256 amountOfAssetToken
)
external
revertIfZero(amountOfAssetToken)
revertIfNotAllowedToken(token) // @>
{
AssetToken assetToken = s_tokenToAssetToken[token]; // @>
​
// src/protocol/ThunderLoan.sol L227-L244
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;
}
}
​
// src/protocol/ThunderLoan.sol L260-L262
function isAllowedToken(IERC20 token) public view returns (bool) {
return address(s_tokenToAssetToken[token]) != address(0); // @>
}
​
// src/protocol/AssetToken.sol L76-L78 (only the ThunderLoan proxy can move the underlying)
function transferUnderlyingTo(address to, uint256 amount) external onlyThunderLoan {
i_underlying.safeTransfer(to, amount);
}
​
// src/upgradedProtocol/ThunderLoanUpgraded.sol has the same code: redeem gate at L165,
// lookup at L167, re-list deploy at L232, delete at L238, isAllowedToken at L258-L260.

Risk

Likelihood:

  • Only the owner can trigger it. setAllowedToken is onlyOwner and the README treats the owner as a trusted role, so no unprivileged user or outside attacker can cause the freeze.

  • It occurs when the owner delists a token whose AssetToken still has outstanding supply. Delisting is an intended feature (for example, a response to a depegged or compromised token), and neither the code nor the README warns that it blocks withdrawals, so a routine, non-malicious use of the feature is enough.

  • Re-listing the same token afterwards, a natural reaction to an unintended delist, does not undo it: it replaces the AssetToken in the mapping and leaves the old one unreachable.

Impact:

  • All LP principal and accrued fees for the delisted token become unwithdrawable. redeem reverts with ThunderLoan__NotAllowedToken for every holder and every amount, including type(uint256).max, and ThunderLoanUpgraded behaves the same way. In the PoC, 1,000,300e18 (1,000,000e18 of deposits plus a 300e18 flash-loan fee) stays in the old AssetToken and every LP share is unchanged.

  • The frozen liquidity also stops earning, because flashloan for the token reverts with ThunderLoan__NotEnoughTokenBalance(0, amount). Re-listing does not help. It deploys a new AssetToken with zero supply. An old holder's redeem of an explicit amount then reverts with "ERC20: burn amount exceeds balance", and redeem(type(uint256).max) succeeds but pays 0.

  • The funds are frozen, not lost, and no attacker gains anything. The owner can recover them only by writing a custom implementation, upgrading the proxy through UUPS, calling the old AssetToken's transferUnderlyingTo from the proxy, and distributing the funds pro rata outside the protocol, as the PoC does. A temporary freeze of LP funds that only the owner can lift fits Medium, not High.

Proof of Concept

The PoC test/PoC_TL-05.t.sol deploys ThunderLoan and ThunderLoanUpgraded behind ERC1967Proxy. Two LPs deposit 1,000,000e18 and one honest flash loan runs, so the pool holds 1,000,300e18 including fees. The tests assert the following after setAllowedToken(token, false): every redeem, including type(uint256).max, reverts with ThunderLoan__NotAllowedToken; neither the LPs nor the owner can call transferUnderlyingTo on the old AssetToken; flashloan reverts with NotEnoughTokenBalance(0, amount); re-listing deploys a new, empty AssetToken while the old one keeps all funds; and the funds come back only through a custom UUPS rescue implementation that is not part of the protocol. Run it with forge test --match-path test/PoC_TL-05.t.sol -vv; a baseline test also confirms that redeem works while the token is listed.

File test/PoC_TL-05.t.sol; run FOUNDRY_FFI=false forge test --match-path test/PoC_TL-05.t.sol -vv.

// SPDX-License-Identifier: MIT
pragma solidity 0.8.20;
​
import { Test, console } from "forge-std/Test.sol";
import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import { ERC20Mock } from "@openzeppelin/contracts/mocks/ERC20Mock.sol";
import { ERC1967Proxy } from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol";
​
import { ThunderLoan } from "../src/protocol/ThunderLoan.sol";
import { ThunderLoanUpgraded } from "../src/upgradedProtocol/ThunderLoanUpgraded.sol";
import { AssetToken } from "../src/protocol/AssetToken.sol";
import { MockPoolFactory } from "./mocks/MockPoolFactory.sol";
​
/// Honest flash-loan receiver: repays principal + fee through repay().
/// Used only to put real accrued fees into the pool before the delist.
contract HonestReceiverTL05 {
function executeOperation(
address token,
uint256 amount,
uint256 fee,
address, /* initiator */
bytes calldata /* params */
)
external
returns (bool)
{
IERC20(token).approve(msg.sender, amount + fee);
ThunderLoan(msg.sender).repay(IERC20(token), amount + fee);
return true;
}
}
​
/// The bespoke rescue implementation the owner would have to write and push
/// through UUPS. It is NOT part of the protocol; it only exists to show that
/// recovery is impossible with the shipped code and possible only via upgrade.
contract ThunderLoanRescueTL05 is ThunderLoan {
function rescueUnderlying(AssetToken orphan, address to, uint256 amount) external onlyOwner {
orphan.transferUnderlyingTo(to, amount);
}
}
​
contract PoC_TL05 is Test {
MockPoolFactory factory;
ERC20Mock usdc; // stand-in for an allowed ERC20 (18 decimals, price 1e18 in the mock pool)
ThunderLoan tl; // proxy, V1 implementation
​
address owner = makeAddr("owner");
address lp1 = makeAddr("lp1");
address lp2 = makeAddr("lp2");
address lp3 = makeAddr("lp3");
​
uint256 constant LP1_DEPOSIT = 600_000e18;
uint256 constant LP2_DEPOSIT = 400_000e18; // total 1,000,000
uint256 constant FLASH_AMOUNT = 100_000e18;
uint256 constant FLASH_FEE = 300e18; // 0.3% at price 1e18
​
function setUp() public {
factory = new MockPoolFactory();
usdc = new ERC20Mock();
factory.createPool(address(usdc));
​
ThunderLoan impl = new ThunderLoan();
tl = ThunderLoan(address(new ERC1967Proxy(address(impl), "")));
vm.prank(owner);
tl.initialize(address(factory));
vm.prank(owner);
tl.setAllowedToken(IERC20(address(usdc)), true);
}
​
// ------------------------------------------------------------------ helpers
​
function _deposit(address tlAddr, address lp, uint256 amount) internal {
usdc.mint(lp, amount);
vm.startPrank(lp);
usdc.approve(tlAddr, amount);
ThunderLoan(tlAddr).deposit(IERC20(address(usdc)), amount);
vm.stopPrank();
}
​
/// LP deposits + one honest flash loan, so the pool holds principal AND accrued fees.
function _fundPool(address tlAddr) internal returns (AssetToken a1) {
_deposit(tlAddr, lp1, LP1_DEPOSIT);
_deposit(tlAddr, lp2, LP2_DEPOSIT);
HonestReceiverTL05 r = new HonestReceiverTL05();
usdc.mint(address(r), FLASH_FEE);
ThunderLoan(tlAddr).flashloan(address(r), IERC20(address(usdc)), FLASH_AMOUNT, "");
a1 = ThunderLoan(tlAddr).getAssetFromToken(IERC20(address(usdc)));
}
​
/// Shares an LP can redeem without hitting the (separate) deposit-rate bug in V1:
/// min(lp shares, shares the pool balance can actually pay for).
function _safeShares(AssetToken at, address lp) internal view returns (uint256) {
uint256 payable_ = (usdc.balanceOf(address(at)) * at.EXCHANGE_RATE_PRECISION()) / at.getExchangeRate();
uint256 held = at.balanceOf(lp);
return held < payable_ ? held : payable_;
}
​
function _notAllowed() internal view returns (bytes memory) {
return abi.encodeWithSelector(ThunderLoan.ThunderLoan__NotAllowedToken.selector, address(usdc));
}
​
// ------------------------------------------------------------------ tests
​
/// BASELINE: while the token is listed, an LP redeems normally.
function test_Baseline_ListedToken_LPRedeems() public {
AssetToken a1 = _fundPool(address(tl));
uint256 shares = _safeShares(a1, lp1) / 2;
uint256 expected = (shares * a1.getExchangeRate()) / a1.EXCHANGE_RATE_PRECISION();
​
vm.prank(lp1);
tl.redeem(IERC20(address(usdc)), shares);
​
assertEq(usdc.balanceOf(lp1), expected, "LP receives underlying while listed");
assertGt(expected, 0);
console.log("[baseline] lp1 redeemed underlying:", expected);
}
​
/// LIMB 1: after a routine owner delist, EVERY LP's redeem reverts; funds (principal + fees)
/// stay locked inside the old AssetToken and no caller can move them.
function test_Freeze_AfterDelist_EveryRedeemReverts() public {
AssetToken a1 = _fundPool(address(tl));
uint256 locked = usdc.balanceOf(address(a1));
assertEq(locked, LP1_DEPOSIT + LP2_DEPOSIT + FLASH_FEE, "pool = principal + accrued fee");
uint256 lp1Shares = _safeShares(a1, lp1);
uint256 lp2Shares = _safeShares(a1, lp2);
uint256 supplyBefore = a1.totalSupply();
​
// Owner delists (non-malicious, intended feature).
vm.prank(owner);
tl.setAllowedToken(IERC20(address(usdc)), false);
assertFalse(tl.isAllowedToken(IERC20(address(usdc))));
assertEq(address(tl.getAssetFromToken(IERC20(address(usdc)))), address(0), "mapping deleted, A1 forgotten");
​
// Every LP, every amount (incl. max) -> NotAllowedToken.
vm.expectRevert(_notAllowed());
vm.prank(lp1);
tl.redeem(IERC20(address(usdc)), lp1Shares);
​
vm.expectRevert(_notAllowed());
vm.prank(lp2);
tl.redeem(IERC20(address(usdc)), lp2Shares);
​
vm.expectRevert(_notAllowed());
vm.prank(lp1);
tl.redeem(IERC20(address(usdc)), type(uint256).max);
​
// The AssetToken itself only obeys ThunderLoan; neither LPs nor the owner can pull funds.
vm.expectRevert(AssetToken.AssetToken__onlyThunderLoan.selector);
vm.prank(lp1);
a1.transferUnderlyingTo(lp1, 1);
​
vm.expectRevert(AssetToken.AssetToken__onlyThunderLoan.selector);
vm.prank(owner);
a1.transferUnderlyingTo(owner, 1);
​
// The frozen liquidity also stops earning: flashloan now looks at address(0)'s balance.
HonestReceiverTL05 rcv = new HonestReceiverTL05();
vm.expectRevert(
abi.encodeWithSelector(ThunderLoan.ThunderLoan__NotEnoughTokenBalance.selector, 0, FLASH_AMOUNT)
);
tl.flashloan(address(rcv), IERC20(address(usdc)), FLASH_AMOUNT, "");
​
// Nothing moved.
assertEq(usdc.balanceOf(address(a1)), locked, "all funds still sitting in A1");
assertEq(a1.totalSupply(), supplyBefore, "LP shares untouched but unredeemable");
assertEq(usdc.balanceOf(lp1), 0);
assertEq(usdc.balanceOf(lp2), 0);
console.log("[freeze] underlying locked in A1:", locked);
console.log("[freeze] A1 shares outstanding :", supplyBefore);
}
​
/// LIMB 2: re-listing deploys a fresh AssetToken A2 with no link to A1.
/// Old holders' redeem now resolves to A2 and reverts in burn; max-redeem "succeeds" paying 0.
function test_Orphan_RelistDeploysNewAssetToken() public {
AssetToken a1 = _fundPool(address(tl));
uint256 locked = usdc.balanceOf(address(a1));
uint256 lp1A1 = a1.balanceOf(lp1);
​
vm.startPrank(owner);
tl.setAllowedToken(IERC20(address(usdc)), false);
AssetToken a2 = tl.setAllowedToken(IERC20(address(usdc)), true);
vm.stopPrank();
​
assertTrue(tl.isAllowedToken(IERC20(address(usdc))));
assertTrue(address(a2) != address(a1), "re-list deployed a NEW AssetToken");
assertEq(address(tl.getAssetFromToken(IERC20(address(usdc)))), address(a2), "mapping now points to A2");
assertEq(a2.totalSupply(), 0, "A2 starts with zero supply");
assertEq(a2.getExchangeRate(), 1e18, "A2 starts at rate 1e18");
assertEq(usdc.balanceOf(address(a2)), 0, "A2 holds none of the old liquidity");
​
// Old LP redeem burns from A2 -> reverts.
vm.expectRevert(bytes("ERC20: burn amount exceeds balance"));
vm.prank(lp1);
tl.redeem(IERC20(address(usdc)), lp1A1 / 2);
​
// max-redeem reads A2.balanceOf(lp1) == 0 and silently pays nothing.
vm.prank(lp1);
tl.redeem(IERC20(address(usdc)), type(uint256).max);
assertEq(usdc.balanceOf(lp1), 0, "max-redeem returned 0 underlying");
​
// Even with fresh liquidity in A2, old holders cannot reach anything.
_deposit(address(tl), lp3, 50_000e18);
vm.expectRevert(bytes("ERC20: burn amount exceeds balance"));
vm.prank(lp1);
tl.redeem(IERC20(address(usdc)), 1e18);
​
assertEq(usdc.balanceOf(address(a1)), locked, "A1 still holds all old funds");
assertEq(a1.balanceOf(lp1), lp1A1, "lp1 still holds A1 shares, worthless in protocol");
console.log("[orphan] A1:", address(a1));
console.log("[orphan] A2:", address(a2));
console.log("[orphan] underlying orphaned in A1:", usdc.balanceOf(address(a1)));
}
​
/// Same freeze on the upgraded implementation (identical delist + redeem gate).
function test_Freeze_ThunderLoanUpgraded() public {
ThunderLoanUpgraded impl = new ThunderLoanUpgraded();
ThunderLoanUpgraded tlu = ThunderLoanUpgraded(address(new ERC1967Proxy(address(impl), "")));
vm.startPrank(owner);
tlu.initialize(address(factory));
tlu.setAllowedToken(IERC20(address(usdc)), true);
vm.stopPrank();
​
_deposit(address(tlu), lp1, LP1_DEPOSIT);
AssetToken a1 = tlu.getAssetFromToken(IERC20(address(usdc)));
uint256 shares = a1.balanceOf(lp1);
​
// Baseline on upgraded: a small redeem works while listed.
vm.prank(lp1);
tlu.redeem(IERC20(address(usdc)), 1e18);
assertEq(usdc.balanceOf(lp1), 1e18, "upgraded baseline redeem works");
​
vm.prank(owner);
tlu.setAllowedToken(IERC20(address(usdc)), false);
​
vm.expectRevert(
abi.encodeWithSelector(ThunderLoanUpgraded.ThunderLoan__NotAllowedToken.selector, address(usdc))
);
vm.prank(lp1);
tlu.redeem(IERC20(address(usdc)), shares - 1e18);
​
vm.prank(owner);
AssetToken a2 = tlu.setAllowedToken(IERC20(address(usdc)), true);
assertTrue(address(a2) != address(a1));
vm.expectRevert(bytes("ERC20: burn amount exceeds balance"));
vm.prank(lp1);
tlu.redeem(IERC20(address(usdc)), shares - 1e18);
​
assertEq(usdc.balanceOf(address(a1)), LP1_DEPOSIT - 1e18, "upgraded: funds stuck in A1");
console.log("[upgraded] underlying stuck in A1:", usdc.balanceOf(address(a1)));
}
​
/// LIMB 3 / severity bound: shipped code has no recovery path, but the same trusted owner
/// CAN recover via a bespoke UUPS upgrade. Funds are frozen, not destroyed.
function test_Recovery_OnlyViaBespokeUUPSUpgrade() public {
AssetToken a1 = _fundPool(address(tl));
uint256 locked = usdc.balanceOf(address(a1));
​
vm.startPrank(owner);
tl.setAllowedToken(IERC20(address(usdc)), false);
tl.setAllowedToken(IERC20(address(usdc)), true); // A2, A1 orphaned
vm.stopPrank();
​
// Push a custom rescue implementation (not part of the protocol).
ThunderLoanRescueTL05 rescueImpl = new ThunderLoanRescueTL05();
vm.prank(owner);
tl.upgradeTo(address(rescueImpl));
​
// Owner hand-distributes A1's underlying pro rata (off-protocol accounting).
uint256 supply = a1.totalSupply();
uint256 toLp1 = (locked * a1.balanceOf(lp1)) / supply;
uint256 toLp2 = locked - toLp1;
vm.startPrank(owner);
ThunderLoanRescueTL05(address(tl)).rescueUnderlying(a1, lp1, toLp1);
ThunderLoanRescueTL05(address(tl)).rescueUnderlying(a1, lp2, toLp2);
vm.stopPrank();
​
assertEq(usdc.balanceOf(address(a1)), 0, "rescue upgrade drains A1");
assertEq(usdc.balanceOf(lp1) + usdc.balanceOf(lp2), locked, "LPs made whole only via custom upgrade");
console.log("[recovery] recovered via bespoke upgrade:", locked);
}
}

Recommended Mitigation

--- a/src/protocol/ThunderLoan.sol
+++ b/src/protocol/ThunderLoan.sol
@@ -82,4 +82,5 @@
error ThunderLoan__AlreadyAllowed();
error ThunderLoan__ExhangeRateCanOnlyIncrease();
error ThunderLoan__NotCurrentlyFlashLoaning();
error ThunderLoan__BadNewFee();
+ error ThunderLoan__AssetTokenHasOutstandingSupply(uint256 totalSupply);
@@ -238,5 +239,8 @@
} else {
AssetToken assetToken = s_tokenToAssetToken[token];
+ if (address(assetToken) != address(0) && assetToken.totalSupply() != 0) {
+ revert ThunderLoan__AssetTokenHasOutstandingSupply(assetToken.totalSupply());
+ }
delete s_tokenToAssetToken[token];
emit AllowedTokenSet(token, assetToken, allowed);
return assetToken;
--- a/src/upgradedProtocol/ThunderLoanUpgraded.sol
+++ b/src/upgradedProtocol/ThunderLoanUpgraded.sol
@@ -82,4 +82,5 @@
error ThunderLoan__AlreadyAllowed();
error ThunderLoan__ExhangeRateCanOnlyIncrease();
error ThunderLoan__NotCurrentlyFlashLoaning();
error ThunderLoan__BadNewFee();
+ error ThunderLoan__AssetTokenHasOutstandingSupply(uint256 totalSupply);
@@ -236,5 +237,8 @@
} else {
AssetToken assetToken = s_tokenToAssetToken[token];
+ if (address(assetToken) != address(0) && assetToken.totalSupply() != 0) {
+ revert ThunderLoan__AssetTokenHasOutstandingSupply(assetToken.totalSupply());
+ }
delete s_tokenToAssetToken[token];
emit AllowedTokenSet(token, assetToken, allowed);
return assetToken;
Updates

Lead Judging Commences

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