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.
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.
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.
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.
The contest is live. Earn rewards by submitting a finding.
Submissions are being reviewed by our AI judge. Results will be available in a few minutes.
View all submissionsThe contest is complete and the rewards are being distributed.