Thunder Loan

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

getCalculatedFee() uses a WETH-denominated value as a token-quantity fee, silently breaking the documented 0.3% rate for any non-1:1-priced token

Root + Impact

Description

  • 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.

function getCalculatedFee(IERC20 token, uint256 amount) public view returns (uint256 fee) {
@> uint256 valueOfBorrowedToken = (amount * getPriceInWeth(address(token))) / s_feePrecision; // a WETH-denominated VALUE
@> fee = (valueOfBorrowedToken * s_flashLoanFee) / s_feePrecision; // returned as-is, but callers expect TOKEN units
}

Risk

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.

Proof of Concept

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.

// SPDX-License-Identifier: MIT
pragma solidity 0.8.20;
import { Test, console } from "forge-std/Test.sol";
import { ThunderLoan } from "../src/protocol/ThunderLoan.sol";
import { AssetToken } from "../src/protocol/AssetToken.sol";
import { ERC20Mock } from "@openzeppelin/contracts/mocks/ERC20Mock.sol";
import { ERC1967Proxy } from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol";
import { MockFlashLoanReceiver } from "./mocks/MockFlashLoanReceiver.sol";
contract HonestPricedPool {
uint256 private immutable i_price;
constructor(uint256 price) { i_price = price; }
function getPriceOfOnePoolTokenInWeth() external view returns (uint256) { return i_price; }
}
contract ConfigurablePoolFactory {
mapping(address => address) private s_pools;
function setPool(address token, address pool) external { s_pools[token] = pool; }
function getPool(address token) external view returns (address) { return s_pools[token]; }
}
contract PoC_FeeUnitMismatch_Test is Test {
ThunderLoan thunderLoan;
ConfigurablePoolFactory poolFactory;
ERC20Mock tokenA;
address liquidityProvider = address(123);
address user = address(456);
uint256 constant DEPOSIT_AMOUNT = 1_000e18;
uint256 constant BORROW_AMOUNT = 100e18;
function _deploy(uint256 priceInWeth) internal returns (ThunderLoan) {
ThunderLoan implementation = new ThunderLoan();
poolFactory = new ConfigurablePoolFactory();
tokenA = new ERC20Mock();
HonestPricedPool pool = new HonestPricedPool(priceInWeth);
poolFactory.setPool(address(tokenA), address(pool));
ERC1967Proxy proxy = new ERC1967Proxy(address(implementation), "");
ThunderLoan tl = ThunderLoan(address(proxy));
tl.initialize(address(poolFactory));
tl.setAllowedToken(tokenA, true);
return tl;
}
function testFeeScalesLinearlyWithHonestPrice_NotFixedAt0Point3Percent() public {
uint256 amount = 100e18;
uint256 intendedFixedFee = (amount * 3e15) / 1e18;
thunderLoan = _deploy(1e18);
uint256 feeAt1to1 = thunderLoan.getCalculatedFee(tokenA, amount);
assertEq(feeAt1to1, intendedFixedFee);
uint256 daiLikePrice = 1e18 / 2500;
thunderLoan = _deploy(daiLikePrice);
uint256 feeAtLowPrice = thunderLoan.getCalculatedFee(tokenA, amount);
uint256 wbtcLikePrice = 15e18;
thunderLoan = _deploy(wbtcLikePrice);
uint256 feeAtHighPrice = thunderLoan.getCalculatedFee(tokenA, amount);
assertLt(feeAtLowPrice, intendedFixedFee);
assertGt(feeAtHighPrice, intendedFixedFee);
assertApproxEqRel(feeAtLowPrice * 2500, intendedFixedFee, 0.01e18);
assertEq(feeAtHighPrice, intendedFixedFee * 15);
}
function testRealFlashLoan_FeeIsEffectivelyNegligibleForLowPricedToken() public {
thunderLoan = _deploy(1e18 / 2500);
vm.startPrank(liquidityProvider);
tokenA.mint(liquidityProvider, DEPOSIT_AMOUNT);
tokenA.approve(address(thunderLoan), DEPOSIT_AMOUNT);
thunderLoan.deposit(tokenA, DEPOSIT_AMOUNT);
vm.stopPrank();
AssetToken assetToken = thunderLoan.getAssetFromToken(tokenA);
vm.prank(user);
MockFlashLoanReceiver receiver = new MockFlashLoanReceiver(address(thunderLoan));
uint256 fee = thunderLoan.getCalculatedFee(tokenA, BORROW_AMOUNT);
tokenA.mint(address(receiver), BORROW_AMOUNT);
vm.prank(user);
thunderLoan.flashloan(address(receiver), tokenA, BORROW_AMOUNT, "");
uint256 documentedFee = (BORROW_AMOUNT * 3e15) / 1e18;
assertLt(fee * 100, documentedFee);
}
}

Recommended Mitigation

function getCalculatedFee(IERC20 token, uint256 amount) public view returns (uint256 fee) {
- uint256 valueOfBorrowedToken = (amount * getPriceInWeth(address(token))) / s_feePrecision;
- fee = (valueOfBorrowedToken * s_flashLoanFee) / s_feePrecision;
+ // Flat percentage of the borrowed token amount itself - no price conversion needed,
+ // since the fee should be denominated in the same units as the loan being repaid.
+ fee = (amount * s_flashLoanFee) / s_feePrecision;
}

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.

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!