getCalculatedFee() computes valueOfBorrowedToken = amount * getPriceInWeth(token) / s_feePrecision. This is a WETH-denominated value, not a quantity of the borrowed token. It then computes fee = valueOfBorrowedToken * s_flashLoanFee / s_feePrecision and returns that value as-is.
That returned fee is fed directly into assetToken.updateExchangeRate(fee) and compared against token.balanceOf(...) deltas in flashloan()'s repayment check - both of which contractually expect a quantity denominated in the borrowed token's own units, not WETH-value units. AssetToken.updateExchangeRate's own inline comment ("if fee is 1e18 and totalSupply is 2e18...") confirms fee must be in the same units as totalSupply() (i.e. token/share units).
Algebraically, fee = amount * (price / s_feePrecision) * (s_flashLoanFee / s_feePrecision). The intended flat rate (amount * s_flashLoanFee / s_feePrecision, i.e. a fixed 0.3%) is only what actually gets charged when price == s_feePrecision (i.e. the token is pegged exactly 1:1 to WETH). For any other price - which is the normal case for virtually every real token (the README explicitly lists USDT/USDC/PAXG/BNB/ZIL/KNC as supported tokens, none pegged 1:1 to WETH) - the fee is off by exactly a factor of price / s_feePrecision.
Critically, this requires zero attacker action or manipulation - it fires on every single ordinary deposit()/flashloan() call for any token not priced at exactly 1 WETH, purely from an honestly-reported, static price. It is a distinct, independent root cause from the separate oracle-manipulation issue (which shows the same formula can also be actively manipulated) - this defect persists even with a perfectly honest, unmanipulated oracle.
src/upgradedProtocol/ThunderLoanUpgraded.sol::getCalculatedFee() (lines 244-249) contains the byte-for-byte identical flawed formula, so the bug survives the planned upgrade.
Likelihood:
Reason 1 // Fires automatically on every ordinary deposit()/flashloan() call for any allowed token whose price isn't exactly 1:1 with WETH - no special timing, privileges, or attacker action required.
Reason 2 // Essentially every real-world ERC20 the protocol documents supporting (stablecoins, wrapped BTC, etc.) is priced away from 1:1 WETH, so this is the normal operating case, not an edge case.
Impact:
Impact 1 // For tokens priced well below 1 WETH (e.g. a stablecoin), LPs are systematically shortchanged - the actual fee collected can be orders of magnitude below the documented 0.3%, continuously, on every flashloan, forever.
Impact 2 // For tokens priced above 1 WETH, borrowers are systematically overcharged relative to the documented rate, which can make flashloans of such tokens uneconomical or effectively unusable at the misquoted rate.
Ran with forge test --match-path "test/PoC_6.t.sol" -vv: both tests pass. testFeeScalesLinearlyWithHonestPrice_NotFixedAt0Point3Percent shows, with a 100% honest static price and zero manipulation: at price=1e18 (1:1) the fee exactly matches the documented 0.3% (300000000000000000); at price=1e18/2500 (DAI/USDC-like) the fee is exactly 2500x smaller (120000000000000); at price=15e18 (WBTC-like) the fee is exactly 15x larger (4500000000000000000) - precisely matching the algebraic prediction fee = documented_fee * (price / 1e18). testRealFlashLoan_FeeIsEffectivelyNegligibleForLowPricedToken runs a complete, ordinary flashloan of a DAI-like token (price=1/2500 WETH): an LP deposits 1000e18, a borrower takes a normal 100e18 flashloan and repays fully, and the fee actually credited is only 120000000000000 - under 1% of the 300000000000000000 the protocol's own code comment ("0.3% ETH fee") promises, with zero attacker action. Full regression suite (19 tests across 5 files) passing, no regressions.
Two acceptable fixes: (1) Simplest, and consistent with the "flat fee" semantics the rest of the codebase assumes: drop the WETH-value conversion entirely and compute fee = amount * s_flashLoanFee / s_feePrecision directly on the borrowed token amount. (2) If the intent genuinely is "charge X% of the loan's ETH-equivalent value," convert the computed feeInWeth back into the borrowed token's own units by dividing by the same price again (fee = feeInWeth * s_feePrecision / getPriceInWeth(token)) before returning it. Either way, add a regression test that locks in the expected fee for a token priced away from 1:1 (e.g. price = 1e18/2500 and price = 15e18) in both src/protocol/ThunderLoan.sol and src/upgradedProtocol/ThunderLoanUpgraded.sol, so this unit mismatch cannot silently regress or resurface post-upgrade.
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.