Thunder Loan

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

Integer-truncation DoS in `updateExchangeRate`: zero or tiny fees revert every deposit and flash loan

Description

The exchange rate is updated by rate = rate * (supply + fee) / supply. Because this is integer arithmetic,
the step is always zero when the fee is small relative to total supply (or exactly 0). Since the function
reverts when the rate cannot strictly increase, any such call bounces — taking the whole flashloan (or v1
deposit) with it.

// src/protocol/AssetToken.sol:80-91 — @> truncation: newRate == oldRate whenever fee*rate < supply
function updateExchangeRate(uint256 fee) external onlyThunderLoan {
uint256 newExchangeRate = s_exchangeRate * (totalSupply() + fee) / totalSupply();
if (newExchangeRate <= s_exchangeRate) {
// @> fee==0, or fee too small to move the rate -> hard revert
revert AssetToken__ExhangeRateCanOnlyIncrease(s_exchangeRate, newExchangeRate);
}
s_exchangeRate = newExchangeRate;
}
// src/protocol/ThunderLoan.sol:180-195 (v1) / src/upgradedProtocol/ThunderLoanUpgraded.sol:192-194 (v2)
// @> flashloan inflates the rate with the oracle fee BEFORE disbursing funds:
uint256 fee = getCalculatedFee(token, amount); // fee can round to 0 for small/cheap loans
assetToken.updateExchangeRate(fee); // @> reverts -> the flash loan never happens
// src/protocol/ThunderLoan.sol:147-156 — @> v1 deposit takes the same path BEFORE transferFrom
uint256 calculatedFee = getCalculatedFee(token, amount);
assetToken.updateExchangeRate(calculatedFee); // @> reverts -> deposit never happens

Root Cause

updateExchangeRate uses round-down division with a strict-increase requirement. Any operation whose oracle
fee fails to shift the rate (fee == 0, or fee * rate < supply) reverts the entire external call, including
state-changing flows where the fee is not the point of the transaction.

Risk

Likelihood: Medium-High. Any low-fee borrow (fee of a few wei against a large supply), borrow of a
cheap-priced token, borrow pushed below the rounding threshold by an oracle price crush (see the oracle
finding), or v1 deposit in those states hits the revert — with no way for the caller to opt out.

Impact:

  • Flash loans below a value-dependent threshold are permanently blocked; in v1, LP onboarding (deposit)
    is blocked for those tokens too (a first, zero-supply deposit with fee==0 also hits the div-by-zero panic).

  • Constrained by the oracle price, an attacker can deliberately crush the price to force the fee to round to
    0, DoSing every flash loan on a token until the pool price recovers.

Proof of Concept

test/poc/PocExchangeRateDoS.t.sol (real code, both tests PASS):

contract DummyReceiver {
function executeOperation(address, uint256, uint256, address, bytes calldata) external pure returns (bool) {
return true;
}
}
// pool seeded 200k tokenA : 200M weth; 1 tokenA = 1000 weth; fee = 0.3% of value
function test_PoC_SmallFeeFlashLoan_Reverts() public {
// LP funds 100k tokenA
address lp2 = makeAddr("lp2");
tokenA.mint(lp2, 100_000e18);
vm.startPrank(lp2);
tokenA.approve(address(tl), type(uint256).max);
tl.deposit(tokenA, 100_000e18);
vm.stopPrank();
DummyReceiver r = new DummyReceiver();
// Borrow 1 wei. fee=3 wei. updateExchangeRate can't move the rate -> reverts the whole flashloan.
vm.expectRevert();
tl.flashloan(address(r), tokenA, 1, ""); // @> legit flash loan is DoS'd
}
function test_PoC_TinyFee_AccrualLoss() public {
// LP funds 100k tokenA
address large = makeAddr("large");
tokenA.mint(large, 100_000e18);
vm.startPrank(large);
tokenA.approve(address(tl), type(uint256).max);
tl.deposit(tokenA, 100_000e18);
vm.stopPrank();
uint256 rateLarge = tl.getAssetFromToken(tokenA).getExchangeRate();
uint256 supplyLarge = tl.getAssetFromToken(tokenA).totalSupply();
uint256 fee = tl.getCalculatedFee(tokenA, 1); // 1 * (1e21) * 3e15 / 1e36 = 3 wei
assertEq(fee, 3, "3 wei fee for 1 wei token borrow");
// (supply + 3)*rate/supply truncates to rate == the exchange rate cannot increase -> revert
uint256 newRate = (rateLarge * (supplyLarge + fee)) / supplyLarge;
assertEq(newRate, rateLarge, "fee rounds away -> exchange rate cannot increase");
}

Run: forge test --match-contract PocExchangeRateDoS -vv (both tests PASS).

Recommended Mitigation

  • In updateExchangeRate, return early (or no-op, with a bounded delay) when the fee is zero or would round to
    zero, instead of reverting:
    if (fee == 0) return; and compute the step with a + 1 guard or a scaled numerator so a tiny fee can never
    hard-fail an unrelated operation.

  • Call updateExchangeRate only when fee > 0, and in v1 remove the fee-step from deposit entirely.

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!