Thunder Loan

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

updateExchangeRate()'s strict <= guard rejects a legitimate zero-growth rate, making flashloan() revert for any dust-sized borrow amount

Root + Impact

Description

  • getCalculatedFee() rounds down to exactly 0 for small enough "dust" borrow amounts (e.g. at the default 0.3% fee and a 1:1 price, any amount <= 333 wei truncates to fee = 0).

  • When fee == 0, AssetToken.updateExchangeRate(0) computes newExchangeRate = s_exchangeRate * (totalSupply + 0) / totalSupply, which is mathematically exactly equal to s_exchangeRate - a completely legitimate "no growth" result for a rounded-to-zero fee.

  • But the guard is if (newExchangeRate <= s_exchangeRate) revert AssetToken__ExhangeRateCanOnlyIncrease(...) - strict <=, not <. This treats "no change" the same as "the invariant was violated," reverting the entire flashloan() call even though the exchange rate never actually decreased and the borrower repaid in full.

  • This means flashloan() is unconditionally unusable for any borrow amount whose fee rounds down far enough - regardless of how large the pool is or how honestly the borrower repays. The failure has nothing to do with repayment ability: the revert happens inside updateExchangeRate(), before any balance check.

  • Additional finding while building the PoC: the "unusable amount" range is not capped at 333 wei as a naive fee-rounding analysis alone would suggest. With a large, realistic pool (totalSupply = 1,000,000e18), even amount = 334 wei - which gives a non-zero calculated fee of 1 wei - still reverts, because updateExchangeRate's own internal division (oldRate * (totalSupply + fee) / totalSupply) rounds the effect of that 1-wei fee back down to exactly oldRate when totalSupply is large relative to fee. In other words, the larger a pool grows, the larger the "stuck" borrow-amount range becomes - this is not a narrow, one-off dust edge case.

  • src/upgradedProtocol/ThunderLoanUpgraded.sol::flashloan() shares the same AssetToken.sol and the same getCalculatedFee() logic, so it is affected identically.

// AssetToken.sol
function updateExchangeRate(uint256 fee) external onlyThunderLoan {
uint256 newExchangeRate = s_exchangeRate * (totalSupply() + fee) / totalSupply();
@> if (newExchangeRate <= s_exchangeRate) { // should be `<`: newRate == oldRate is a VALID zero-fee outcome
revert AssetToken__ExhangeRateCanOnlyIncrease(s_exchangeRate, newExchangeRate);
}
s_exchangeRate = newExchangeRate;
}

Risk

Likelihood:

  • Reason 1 // Triggers deterministically and automatically for any borrow amount that rounds its fee down far enough relative to getCalculatedFee()'s truncation and updateExchangeRate()'s own internal rounding against totalSupply - no attacker action or special conditions needed, just an unlucky (but realistic, especially in a large pool) amount.

  • Reason 2 // The affected amount range grows with pool size (not fixed at "333 wei"), and the failure hits any normal, honest user - not just contrived edge cases - though real flash-loan use cases (arbitrage, liquidations, etc.) typically don't request amounts this small, which keeps the practical likelihood at Medium rather than High.

Impact:

  • Impact 1 // No funds are lost - the whole transaction reverts and all state is rolled back. This is a function-correctness / availability issue, not a fund-safety one.

  • Impact 2 // flashloan(), a core advertised feature, is unconditionally unusable for a real (and pool-size-dependent) range of borrow amounts, with no workaround available to the caller.

Proof of Concept

Ran with forge test --match-path "test/PoC_10.t.sol" -vv: all 4 tests pass. test_feeRoundsDownToExactlyZeroForDustAmount confirms getCalculatedFee(tokenA, 300) == 0. test_flashloanRevertsOnDustAmountEvenWithPerfectRepayment seeds a realistic 1,000,000e18 tokenA pool, uses a fully honest MockFlashLoanReceiver that correctly approves and repays amount + fee, and shows flashloan(receiver, tokenA, 300, "") reverts with AssetToken__ExhangeRateCanOnlyIncrease(rate, rate) - not ThunderLoan__NotPaidBack - proving the failure is unrelated to repayment ability. test_evenNonZeroFeeCanStillRevertOnLargePool shows that even amount = 334 (non-zero fee = 1 wei) still reverts against the same large pool, because updateExchangeRate's own division re-truncates the 1-wei effect to zero. test_boundaryAt333WeiDustVsAtLeast334WeiWorks_SmallPool isolates the precise boundary with a small pool: amount = 333 reverts (fee = 0), amount = 334 succeeds (fee = 1, large enough relative to this smaller pool). Full existing suite (17 tests, including a pre-existing HygienePoC.t.sol test pointing at the same root cause) continues to pass, no regressions.

// SPDX-License-Identifier: MIT
pragma solidity 0.8.20;
import { BaseTest } from "./unit/BaseTest.t.sol";
import { AssetToken } from "../src/protocol/AssetToken.sol";
import { ThunderLoan } from "../src/protocol/ThunderLoan.sol";
import { MockFlashLoanReceiver } from "./mocks/MockFlashLoanReceiver.sol";
import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import { ERC20Mock } from "@openzeppelin/contracts/mocks/ERC20Mock.sol";
contract PoC_10_ExchangeRateDustRevert is BaseTest {
address public lp = makeAddr("lp");
MockFlashLoanReceiver public receiver;
function setUp() public override {
super.setUp();
vm.prank(thunderLoan.owner());
thunderLoan.setAllowedToken(tokenA, true);
receiver = new MockFlashLoanReceiver(address(thunderLoan));
tokenA.mint(address(receiver), 1000);
}
function _lpDeposits(uint256 amount) internal {
tokenA.mint(lp, amount);
vm.startPrank(lp);
tokenA.approve(address(thunderLoan), amount);
thunderLoan.deposit(tokenA, amount);
vm.stopPrank();
}
function test_feeRoundsDownToExactlyZeroForDustAmount() public view {
uint256 dustAmount = 300;
uint256 fee = thunderLoan.getCalculatedFee(tokenA, dustAmount);
assertEq(fee, 0, "expected fee to truncate to exactly 0 for this dust amount");
}
function test_flashloanRevertsOnDustAmountEvenWithPerfectRepayment() public {
_lpDeposits(1_000_000e18);
uint256 dustAmount = 300;
uint256 fee = thunderLoan.getCalculatedFee(tokenA, dustAmount);
assertEq(fee, 0);
AssetToken assetToken = thunderLoan.getAssetFromToken(tokenA);
uint256 rateBefore = assetToken.getExchangeRate();
vm.expectRevert(
abi.encodeWithSelector(
AssetToken.AssetToken__ExhangeRateCanOnlyIncrease.selector, rateBefore, rateBefore
)
);
thunderLoan.flashloan(address(receiver), tokenA, dustAmount, "");
}
function test_evenNonZeroFeeCanStillRevertOnLargePool() public {
_lpDeposits(1_000_000e18);
AssetToken assetToken = thunderLoan.getAssetFromToken(tokenA);
uint256 rateBefore = assetToken.getExchangeRate();
uint256 amount = 334;
uint256 fee = thunderLoan.getCalculatedFee(tokenA, amount);
assertGt(fee, 0, "fee itself is nonzero for this amount");
vm.expectRevert(
abi.encodeWithSelector(
AssetToken.AssetToken__ExhangeRateCanOnlyIncrease.selector, rateBefore, rateBefore
)
);
thunderLoan.flashloan(address(receiver), tokenA, amount, "");
}
function test_boundaryAt333WeiDustVsAtLeast334WeiWorks_SmallPool() public {
_lpDeposits(334);
AssetToken assetToken = thunderLoan.getAssetFromToken(tokenA);
uint256 rateBefore = assetToken.getExchangeRate();
uint256 dustAmount = 333;
assertEq(thunderLoan.getCalculatedFee(tokenA, dustAmount), 0);
vm.expectRevert(
abi.encodeWithSelector(
AssetToken.AssetToken__ExhangeRateCanOnlyIncrease.selector, rateBefore, rateBefore
)
);
thunderLoan.flashloan(address(receiver), tokenA, dustAmount, "");
uint256 workingAmount = 334;
assertGt(thunderLoan.getCalculatedFee(tokenA, workingAmount), 0);
thunderLoan.flashloan(address(receiver), tokenA, workingAmount, "");
}
}

Recommended Mitigation

function updateExchangeRate(uint256 fee) external onlyThunderLoan {
uint256 newExchangeRate = s_exchangeRate * (totalSupply() + fee) / totalSupply();
- if (newExchangeRate <= s_exchangeRate) {
+ if (newExchangeRate < s_exchangeRate) {
revert AssetToken__ExhangeRateCanOnlyIncrease(s_exchangeRate, newExchangeRate);
}
s_exchangeRate = newExchangeRate;
}

Change the guard from <= to < so a genuine zero-growth result (rate stays exactly the same) is accepted rather than treated as a violation of the "can only increase" invariant - the invariant's real intent is "must never decrease," which fee >= 0 already guarantees mathematically. As a secondary improvement, consider giving getCalculatedFee() a minimum non-zero fee floor (e.g. 1 wei) or higher-precision intermediate math, to shrink the range of borrow amounts whose fee effect gets rounded away entirely - especially since this range grows with pool size. This fix must be applied to AssetToken.sol (shared by both ThunderLoan.sol and ThunderLoanUpgraded.sol), since both rely on the same contract.

Updates

Lead Judging Commences

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