Thunder Loan

AI First Flight #7
Beginner FriendlyFoundryDeFiOracle
EXP
View results
Submission Details
Impact: high
Likelihood: low
Invalid

Missing Interface Validation on `tswapAddress` in `ThunderLoan.initialize()` Allows Misconfiguration That Permanently Disables Fee Calculation and Bricked Core Protocol Functions

Root + Impact

Description

  • During initialization, `ThunderLoan.initialize()` receives `tswapAddress` as a parameter and passes it directly to `OracleUpgradeable.__Oracle_init()`, which stores it as `s_poolFactory`. This address is then used by `getPriceInWeth()` to fetch token prices, which is called by `getCalculatedFee()` on every `flashloan()` and `deposit()` call.

  • However, `initialize()` performs no validation on `tswapAddress` — it does not verify that the address implements `IPoolFactory` or that it is capable of returning a valid pool address. Since `initializer` prevents re-initialization, passing an incompatible or zero address permanently writes an invalid `s_poolFactory` to storage, causing every call to `flashloan()` and `deposit()` to revert when attempting to fetch the token price, with no recovery path other than a full protocol upgrade.

function initialize(address tswapAddress) external initializer {
__Ownable_init();
__UUPSUpgradeable_init();
@> __Oracle_init(tswapAddress);
s_feePrecision = 1e18;
s_flashLoanFee = 3e15; // 0.3% ETH fee
}

Risk

Likelihood:

  • This issue occurs any time the deployer passes an incorrect, uninitialized, or incompatible address as `tswapAddress` during the one-time initialization call, whether by human error, a misconfigured deploy script, or an untested deployment pipeline.

Impact:

  • Any invalid `tswapAddress` permanently breaks `flashloan()` and `deposit()` since both rely on `getCalculatedFee()` → `getPriceInWeth()` → `IPoolFactory(s_poolFactory).getPool()`, which will revert on every call with no way to recover without a full protocol upgrade.

  • Liquidity providers that already deposited funds before the misconfiguration is discovered cannot call `redeem()` to recover their underlying tokens if the issue also affects the exchange rate calculation, resulting in potential permanent loss of funds.

Proof of Concept

The test demonstrates that passing address(0) as tswapAddress during initialization
permanently bricks core protocol functionality with no recovery path. First, it deploys
a fresh proxy and initializes it with address(0), then proves that both deposit() and
flashloan() revert because every fee calculation chains through getPriceInWeth(), which
calls IPoolFactory(address(0)).getPool() — a call to a non-contract address. Step 3
confirms that the misconfiguration is irreversible since the initializer modifier prevents
any re-initialization attempt. Step 4 shows that even upgrading to ThunderLoanUpgraded
does not fully recover the protocol — while deposit() survives because it no longer calls getCalculatedFee(), flashloan() remains permanently broken since it still depends on
s_poolFactory, which is forever locked to address(0) in storage.

contract POCtest is Test {
ThunderLoan thunderLoanImplementation;
MockPoolFactory mockPoolFactory;
ERC1967Proxy proxy;
ThunderLoan thunderLoan;
ERC20Mock weth;
ERC20Mock tokenA;
ERC20Mock tokenB;
ERC20Mock6Decimals tokenWith6Decimals;
AssetToken assetToken6Decimals;
AssetToken assetTokenA;
AssetToken assetTokenB;
address depositer = makeAddr("depositer");
address flahLoanReceiver = makeAddr("flashLoanReceiver");
address flashLoanAttacker = makeAddr("flashLoanAttacker");
function setUp() public virtual {
thunderLoan = new ThunderLoan();
mockPoolFactory = new MockPoolFactory();
weth = new ERC20Mock();
tokenA = new ERC20Mock();
tokenB = new ERC20Mock();
tokenWith6Decimals = new ERC20Mock6Decimals();
mockPoolFactory.createPool(address(tokenA));
mockPoolFactory.createPool(address(tokenWith6Decimals));
proxy = new ERC1967Proxy(address(thunderLoan), "");
thunderLoan = ThunderLoan(address(proxy));
thunderLoan.initialize(address(mockPoolFactory));
assetTokenB = thunderLoan.setAllowedToken(
IERC20(address(tokenB)),
true
);
assetToken6Decimals = thunderLoan.setAllowedToken(
IERC20(address(tokenWith6Decimals)),
true
);
tokenA.mint(depositer, 50000 * 10 ** tokenA.decimals()); //fund depositer with tokenA
//fund depositer with token with 6 decimals
tokenWith6Decimals.mint(
depositer,
5000 * 10 ** tokenWith6Decimals.decimals()
); // 5000 tokens in 6-decimal representation
}
function testInvalidTswapAddressBreaksProtocolPermanently() public {
// ================================================================
// SETUP: deploy a fresh proxy initialized with address(0) as tswapAddress
// This simulates a misconfigured deploy script
// NOTE: uses a separate proxy from setUp() to isolate the test
// ================================================================
ThunderLoan freshImplementation = new ThunderLoan();
ERC1967Proxy freshProxy = new ERC1967Proxy(
address(freshImplementation),
""
);
ThunderLoan freshThunderLoan = ThunderLoan(address(freshProxy));
// Initialize with address(0) — no validation prevents this
freshThunderLoan.initialize(address(0));
// Allow tokenA so we can attempt deposits
vm.prank(freshThunderLoan.owner());
freshThunderLoan.setAllowedToken(IERC20(address(tokenA)), true);
// STEP 1: prove deposit() is permanently broken
// deposit() → getCalculatedFee() → getPriceInWeth() →
// IPoolFactory(address(0)).getPool() → call to address(0) → revert
uint256 depositAmount = 1000e18;
tokenA.mint(depositer, depositAmount);
vm.startPrank(depositer);
tokenA.approve(address(freshProxy), depositAmount);
vm.expectRevert();
freshThunderLoan.deposit(IERC20(address(tokenA)), depositAmount);
vm.stopPrank();
// STEP 2: prove flashloan() is permanently broken
// same call chain as deposit() → revert on getPriceInWeth()
FlashLoanReceiver receiver = new FlashLoanReceiver(address(freshProxy));
tokenA.mint(address(receiver), 100e18);
vm.expectRevert();
receiver.requestFlashLoan(address(tokenA), 1e18);
// STEP 3: prove re-initialization is impossible
// initializer modifier prevents initialize() from being called again
// the misconfiguration is permanently written to storage
vm.expectRevert();
freshThunderLoan.initialize(address(mockPoolFactory)); // ← try to fix with valid address
// STEP 4: prove the only recovery path is an upgradebut this requires the owner to be aware of the issue and act
ThunderLoanUpgraded fixedImplementation = new ThunderLoanUpgraded();
vm.prank(freshThunderLoan.owner());
freshThunderLoan.upgradeTo(address(fixedImplementation));
ThunderLoanUpgraded fixedThunderLoan = ThunderLoanUpgraded(
address(freshProxy)
);
// Even after upgrade, initialize() of the new implementation
// cannot be called because _initialized flag is already set
vm.expectRevert();
fixedThunderLoan.initialize(address(mockPoolFactory));
// FINAL ASSERTS
// deposit() works in ThunderLoanUpgraded because it no longer calls getCalculatedFee()
// but flashloan() still calls getPriceInWeth() → s_poolFactory = address(0) → revert
tokenA.mint(depositer, depositAmount);
vm.startPrank(depositer);
tokenA.approve(address(freshProxy), depositAmount);
fixedThunderLoan.deposit(IERC20(address(tokenA)), depositAmount); // ← succeeds
vm.stopPrank();
// flashloan still reverts after upgrade — s_poolFactory is still address(0)
FlashLoanReceiver receiverAfterUpgrade = new FlashLoanReceiver(
address(freshProxy)
);
tokenA.mint(address(receiverAfterUpgrade), 100e18);
vm.expectRevert();
receiverAfterUpgrade.requestFlashLoan(address(tokenA), 1e18); // ← still broken
assertEq(fixedThunderLoan.getPoolFactoryAddress(), address(0));
}
}

Recommended Mitigation

Consider validating `tswapAddress` at the beginning of `initialize()` before writing it to storage, by attempting a call to `IPoolFactory.getPool()` wrapped in a `try/catch`. If the call reverts — whether because the address is `address(0)`, an EOA, or an incompatible contract — the initialization fails immediately with a descriptive custom error, preventing an invalid `s_poolFactory` from ever being committed to storage.

function initialize(address tswapAddress) external initializer {
+ try IPoolFactory(tswapAddress).getPool(address(0)) returns (address) {
+ } catch {
+ revert ThunderLoan__InvalidPoolFactory();
+ }
__Ownable_init();
__UUPSUpgradeable_init();
__Oracle_init(tswapAddress);
s_feePrecision = 1e18;
s_flashLoanFee = 3e15;
}
Updates

Lead Judging Commences

ai-first-flight-judge Lead Judge about 3 hours ago
Submission Judgement Published
Invalidated
Reason: Incorrect statement

Support

FAQs

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

Give us feedback!