Thunder Loan

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

updateExchangeRate adds an underlying-denominated fee to a share-denominated supply, compounding the rate past what the pool holds

Root + Impact

Description

updateExchangeRate adds fee, denominated in the underlying token, to totalSupply(), denominated in shares. The two are only interchangeable while the exchange rate is exactly 1.0; once it moves, the multiplier is computed against the wrong base.

// src/protocol/AssetToken.sol
function updateExchangeRate(uint256 fee) external onlyThunderLoan {
@> uint256 newExchangeRate = s_exchangeRate * (totalSupply() + fee) / totalSupply();
// totalSupply() is in SHARES, fee is in UNDERLYING - they are added together

The correct rate is (underlyingHeld + fee) * PRECISION / totalSupply(). The implementation instead multiplies the previous rate by a share-based ratio, so each fee compounds on the already-raised rate rather than tracking the underlying actually held. The two agree only on the first fee, then diverge permanently.

Risk

Likelihood: High

Every flash loan calls updateExchangeRate, so the divergence accumulates during entirely normal operation. No attacker, no special conditions, and users cannot avoid it.

Impact: High

The exchange rate overstates what each share is worth, so the protocol promises more underlying than it holds. The gap grows with every loan and is unrecoverable: depositors who redeem early are paid out of later depositors' principal, and the last holders cannot withdraw at all.

Proof of Concept

Sixty ordinary, fully repaid flash loans against a 1000e18 pool, with every fee honestly paid in.

function test_Finding5_ExchangeRateDivergesFromUnderlying() public {
// LP deposits 1000e18
for (uint256 i; i < 60; i++) {
uint256 amount = 100e18;
uint256 fee = thunderLoan.getCalculatedFee(IERC20(address(tokenA)), amount);
tokenA.mint(address(recv), fee);
thunderLoan.flashloan(address(recv), IERC20(address(tokenA)), amount, "");
}
​
uint256 promised = (assetToken.totalSupply() * assetToken.getExchangeRate())
/ assetToken.EXCHANGE_RATE_PRECISION();
uint256 held = tokenA.balanceOf(address(assetToken));
​
assertGt(promised, held);
}

Result:

exchange rate : 1021214708586860987 (1.0212)
total shares : 1000000000000000000000
promised underlying: 1021214708586860987000 (1021.2 tokenA)
underlying held : 1018000000000000000000 (1018.0 tokenA)
SHORTFALL : 3214708586860987000 (3.21 tokenA)

A second test confirms the consequence: the sole liquidity provider's redeem(type(uint256).max) reverts, so their own position is no longer withdrawable.

Recommended Mitigation

Derive the rate from the underlying actually held rather than compounding the previous rate against share supply. Fees are paid in the underlying, so they belong in the numerator alongside the existing underlying, not added to a share count:

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

This makes the rate an exact statement of underlying-per-share at all times, so redemption can always be honoured. The mismatch is invisible while the rate sits at 1.0, which is why it survives the existing test suite.

Updates

Lead Judging Commences

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