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);
assetToken.updateExchangeRate(fee);
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);
}
s_currentlyFlashLoaning[token] = false;
}
Risk
Likelihood:
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
function testExchangeRateUpdatedBeforeRepayment() public {
AssetToken assetToken = thunderLoan.getAssetFromToken(IERC20(address(token)));
uint256 rateBefore = assetToken.getExchangeRate();
RateRecorderReceiver recorder = new RateRecorderReceiver(
address(thunderLoan),
address(assetToken)
);
token.transfer(address(recorder), FEE_AMOUNT);
thunderLoan.flashloan(address(recorder), IERC20(address(token)), LOAN_AMOUNT, "");
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) {
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 {
uint256 fee = getCalculatedFee(token, amount);
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);
}
assetToken.updateExchangeRate(fee);
s_currentlyFlashLoaning[token] = false;
}