Thunder Loan

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

Flash‑loan fee compounding inflates exchange rate and strands LP withdrawals

Root + Impact

Description

  • Normal behavior
    Liquidity providers should always be able to redeem their shares for the underlying, and flash‑loan fees should increase LP value only by the amount of fees actually paid.

    Specific issue
    updateExchangeRate() compounds fees by the current exchange rate instead of fixed precision, causing the exchange rate to grow faster than the underlying balance after repeated flash loans. Even when fees are paid, the pool becomes insolvent relative to its own exchange rate and full redemptions can revert.

// src/protocol/AssetToken.sol:80-90
function updateExchangeRate(uint256 fee) external onlyThunderLoan {
// ...
uint256 newExchangeRate = @s_exchangeRate * (totalSupply() + fee) / totalSupply()@;
// ...
}

Risk

Likelihood:

  • Flash loans are a core feature; repeated borrowing is normal usage.

  • Exchange rate increases on every flash loan, so compounding happens naturally over time.


Impact:

  • The exchange rate exceeds actual backing, making the pool insolvent.

  • Full LP redemptions revert (withdrawal DoS), and LPs can be underpaid.

Proof of Concept

test_fee_compounding_creates_insolvency_after_repeated_flashloans() passes and shows requiredUnderlying > actualUnderlying, followed by a redeem revert.

  • Fuzz test also passes and confirms the insolvency property.

// SPDX-License-Identifier: MIT
pragma solidity 0.8.20;
import { BaseTest } from "../unit/BaseTest.t.sol";
import { ThunderLoanUpgraded } from "../../src/upgradedProtocol/ThunderLoanUpgraded.sol";
import { AssetToken } from "../../src/protocol/AssetToken.sol";
import { MockFlashLoanReceiver } from "../mocks/MockFlashLoanReceiver.sol";
contract F004_FlashloanFeeCompoundingInsolvency is BaseTest {
address internal liquidityProvider = address(123);
address internal user = address(456);
uint256 internal constant LIQUIDITY = 1_000e18;
uint256 internal constant BORROW_AMOUNT = 100e18;
MockFlashLoanReceiver internal receiver;
function setUp() public override {
super.setUp();
ThunderLoanUpgraded newImplementation = new ThunderLoanUpgraded();
vm.prank(thunderLoan.owner());
thunderLoan.upgradeTo(address(newImplementation));
ThunderLoanUpgraded upgraded = ThunderLoanUpgraded(address(thunderLoan));
vm.prank(upgraded.owner());
upgraded.updateFlashLoanFee(3e15);
vm.prank(upgraded.owner());
upgraded.setAllowedToken(tokenA, true);
vm.startPrank(liquidityProvider);
tokenA.mint(liquidityProvider, LIQUIDITY);
tokenA.approve(address(upgraded), LIQUIDITY);
upgraded.deposit(tokenA, LIQUIDITY);
vm.stopPrank();
vm.prank(user);
receiver = new MockFlashLoanReceiver(address(upgraded));
}
function test_fee_compounding_creates_insolvency_after_repeated_flashloans() public {
ThunderLoanUpgraded upgraded = ThunderLoanUpgraded(address(thunderLoan));
uint256 fee = upgraded.getCalculatedFee(tokenA, BORROW_AMOUNT);
tokenA.mint(address(receiver), fee * 2);
vm.prank(user);
upgraded.flashloan(address(receiver), tokenA, BORROW_AMOUNT, "");
vm.prank(user);
upgraded.flashloan(address(receiver), tokenA, BORROW_AMOUNT, "");
AssetToken asset = upgraded.getAssetFromToken(tokenA);
uint256 actualUnderlying = tokenA.balanceOf(address(asset));
uint256 requiredUnderlying =
(asset.totalSupply() * asset.getExchangeRate()) / asset.EXCHANGE_RATE_PRECISION();
assertLt(actualUnderlying, requiredUnderlying, "exchange rate exceeds actual backing");
uint256 shares = asset.balanceOf(liquidityProvider);
vm.prank(liquidityProvider);
vm.expectRevert();
upgraded.redeem(tokenA, shares);
}
function test_fuzz_fee_compounding_creates_insolvency(uint96 rawAmount) public {
ThunderLoanUpgraded upgraded = ThunderLoanUpgraded(address(thunderLoan));
uint256 amount = bound(uint256(rawAmount), 1e18, BORROW_AMOUNT);
uint256 fee = upgraded.getCalculatedFee(tokenA, amount);
tokenA.mint(address(receiver), fee * 2);
vm.prank(user);
upgraded.flashloan(address(receiver), tokenA, amount, "");
vm.prank(user);
upgraded.flashloan(address(receiver), tokenA, amount, "");
AssetToken asset = upgraded.getAssetFromToken(tokenA);
uint256 actualUnderlying = tokenA.balanceOf(address(asset));
uint256 requiredUnderlying =
(asset.totalSupply() * asset.getExchangeRate()) / asset.EXCHANGE_RATE_PRECISION();
assertLt(actualUnderlying, requiredUnderlying);
}
}

Recommended Mitigation

Option A (compute from actual backing)

- uint256 newExchangeRate = s_exchangeRate * (totalSupply() + fee) / totalSupply();
+ uint256 underlying = i_underlying.balanceOf(address(this));
+ uint256 newExchangeRate = (underlying * EXCHANGE_RATE_PRECISION) / totalSupply();
Updates

Lead Judging Commences

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