Thunder Loan

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

updateExchangeRate() called before loan executes, crediting fee to LPs before repayment is confirmed

Root + Impact

Description

The issue

flashloan() calls assetToken.updateExchangeRate(fee) before transferring funds to the receiver and before the callback executes. The fee is credited to the exchange rate speculatively — before confirming that the borrower will actually pay it. This violates the Checks-Effects-Interactions pattern and creates an accounting window where the exchange rate reflects income that may not yet exist.

function flashloan(...) external {
AssetToken assetToken = s_tokenToAssetToken[token];
uint256 startingBalance = IERC20(token).balanceOf(address(assetToken));
if (amount > startingBalance) {
revert ThunderLoan__NotEnoughTokenBalance(startingBalance, amount);
}
if (!receiverAddress.isContract()) {
revert ThunderLoan__CallerIsNotContract();
}
uint256 fee = getCalculatedFee(token, amount);
// @> Exchange rate updated here — BEFORE loan funds are sent or repayment confirmed
assetToken.updateExchangeRate(fee);
emit FlashLoan(receiverAddress, token, amount, fee, params);
s_currentlyFlashLoaning[token] = true;
// @> Funds sent after rate update
assetToken.transferUnderlyingTo(receiverAddress, amount);
// @> Callback executes after rate update
receiverAddress.functionCall(...);
uint256 endingBalance = token.balanceOf(address(assetToken));
if (endingBalance < startingBalance + fee) {
revert ThunderLoan__NotPaidBack(startingBalance + fee, endingBalance);
}
// @> Rate should be updated here instead
s_currentlyFlashLoaning[token] = false;
}

Risk

Likelihood:

  • Occurs on every single flash loan call — the ordering is unconditional

  • No special conditions required

Impact:

  • Root enabler of H-01 — without this premature update, the deposit-as-repayment attack yields no profit

  • LP redemptions during an in-flight flash loan (not possible within a single tx, but relevant across blocks) receive inflated exchange rates before fees are confirmed

  • Breaks Checks-Effects-Interactions discipline, making the contract fragile to any future additions that interact with the exchange rate during the callback window

Proof of Concept

// Demonstrate rate is updated before repayment:
function testExchangeRateUpdatedBeforeRepayment() public {
AssetToken assetToken = thunderLoan.getAssetFromToken(IERC20(address(token)));
uint256 rateBefore = assetToken.getExchangeRate();
// Deploy a receiver that records the exchange rate mid-callback
RateRecorderReceiver recorder = new RateRecorderReceiver(
address(thunderLoan),
address(assetToken)
);
token.transfer(address(recorder), FEE_AMOUNT);
thunderLoan.flashloan(address(recorder), IERC20(address(token)), LOAN_AMOUNT, "");
// Rate was already higher DURING the callback, before repayment
assertGt(recorder.rateDuringCallback(), rateBefore);
}
contract RateRecorderReceiver is IFlashLoanReceiver {
uint256 public rateDuringCallback;
AssetToken private immutable i_assetToken;
ThunderLoan private immutable i_thunderLoan;
constructor(address thunderLoan, address assetToken) {
i_thunderLoan = ThunderLoan(thunderLoan);
i_assetToken = AssetToken(assetToken);
}
function executeOperation(
address token, uint256 amount, uint256 fee, address, bytes calldata
) external returns (bool) {
// Record rate mid-callback — already inflated before repayment
rateDuringCallback = i_assetToken.getExchangeRate();
IERC20(token).approve(address(i_thunderLoan), amount + fee);
i_thunderLoan.repay(IERC20(token), amount + fee);
return true;
}
}

Recommended Mitigation

Move updateExchangeRate() to after the repayment balance check is confirmed:

function flashloan(...) external {
// ... checks ...
uint256 fee = getCalculatedFee(token, amount);
// @> Removed: assetToken.updateExchangeRate(fee) from here
emit FlashLoan(receiverAddress, token, amount, fee, params);
s_currentlyFlashLoaning[token] = true;
assetToken.transferUnderlyingTo(receiverAddress, amount);
receiverAddress.functionCall(...);
uint256 endingBalance = token.balanceOf(address(assetToken));
if (endingBalance < startingBalance + fee) {
revert ThunderLoan__NotPaidBack(startingBalance + fee, endingBalance);
}
// @> Update exchange rate here — fee is confirmed received
assetToken.updateExchangeRate(fee);
s_currentlyFlashLoaning[token] = false;
}
Updates

Lead Judging Commences

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