Thunder Loan

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

getCalculatedFee takes 0.3% of a loan's WETH value but collects it in borrowed-token units, so the fee scales with token price: cheap tokens pay ~1/2000 of the LP fee, tokens above ~333 WETH pay more than the principal

Root + Impact

Description

  • A flash loan should cost the borrower a fixed 0.3% of the borrowed amount (s_flashLoanFee = 3e15 over a precision of 1e18). The fee is paid in the borrowed token and credited to liquidity providers through the AssetToken exchange rate.

  • getCalculatedFee multiplies the borrowed amount by getPriceInWeth to get its WETH value, then takes 0.3% of that WETH value. flashloan then requires this number back in raw borrowed-token units (startingBalance + fee) and credits it to LPs as underlying units (updateExchangeRate(fee)), with no conversion back. As a result the fee charged in tokens is 0.3% x price/1e18, which is correct only for a token priced at exactly 1 WETH. ThunderLoanUpgraded uses the same formula, and neither contract reads decimals(), so 6-decimal tokens such as USDC and USDT are mis-scaled further, by an amount that depends on the out-of-scope TSwap quote convention.

// src/protocol/ThunderLoan.sol L95-L97 (the fee is described as an ETH/WEI amount)
// The fee in WEI, it should have 18 decimals. Each flash loan takes a flat fee of the token price.
uint256 private s_feePrecision;
uint256 private s_flashLoanFee; // 0.3% ETH fee
​
// src/protocol/ThunderLoan.sol L246-L251 (fee = 0.3% of the loan's WETH value)
function getCalculatedFee(IERC20 token, uint256 amount) public view returns (uint256 fee) {
//slither-disable-next-line divide-before-multiply
uint256 valueOfBorrowedToken = (amount * getPriceInWeth(address(token))) / s_feePrecision; // @>
//slither-disable-next-line divide-before-multiply
fee = (valueOfBorrowedToken * s_flashLoanFee) / s_feePrecision; // @>
}
​
// src/protocol/ThunderLoan.sol L192-L194 and L212-L215 (the same number is credited and collected as borrowed-token units)
uint256 fee = getCalculatedFee(token, amount);
// slither-disable-next-line reentrancy-vulnerabilities-2 reentrancy-vulnerabilities-3
assetToken.updateExchangeRate(fee); // @>
// ...
uint256 endingBalance = token.balanceOf(address(assetToken));
if (endingBalance < startingBalance + fee) { // @>
revert ThunderLoan__NotPaidBack(startingBalance + fee, endingBalance);
}
​
// src/upgradedProtocol/ThunderLoanUpgraded.sol L244-L249 (identical formula; credited at L192, collected at L211)
function getCalculatedFee(IERC20 token, uint256 amount) public view returns (uint256 fee) {
//slither-disable-next-line divide-before-multiply
uint256 valueOfBorrowedToken = (amount * getPriceInWeth(address(token))) / FEE_PRECISION; // @>
//slither-disable-next-line divide-before-multiply
fee = (valueOfBorrowedToken * s_flashLoanFee) / FEE_PRECISION; // @>
}

Risk

Likelihood:

  • It happens on every flash loan of any allowed token whose TSwap WETH price is not exactly 1e18. An ordinary borrower triggers it with a normal flashloan call. No price manipulation, special role or extra setup is needed.

  • It depends on which tokens the owner lists with setAllowedToken. The README names USDC, USDT, PAXG, BNB, ZIL, KNC and STA as supported, and none of them trades at exactly 1 WETH. The size of the error grows with the token's distance from 1 WETH, so tokens priced near 1 WETH are barely affected.

  • The mispricing is deterministic, but the loss builds up gradually through flash-loan volume rather than in one transaction. That is why the rating is Medium rather than High.

Impact:

  • LP yield, the protocol's only income, is wrong by the price factor. At 5e14 WETH per token, a 1,000,000-token loan pays 1.5 tokens instead of 3,000, and the LP redeems exactly that 1/2000 of the advertised yield. This holds in both V1 and V2.

  • Tokens priced above 1 WETH are overcharged: 6% at 20 WETH, 120% at 400 WETH, and more than the principal from about 333.3 WETH per token. A borrower who pays the advertised 0.3% gets the ThunderLoan__NotPaidBack revert, so flash loans of these assets become uneconomic.

  • Principal is not stolen or frozen. The harm is lost or wrongly sized fee income and degraded flash-loan functionality, which fits Medium impact.

Proof of Concept

test/PoC_TL-07.t.sol deploys ThunderLoan and ThunderLoanUpgraded behind ERC1967 proxies with a pool factory whose WETH price can be set per token. This is needed because the repo's MockTSwapPool hardcodes 1e18, the only price at which the formula is correct. The tests assert five results: (1) the fee is exactly 0.3% at a price of 1e18. (2) At 5e14 WETH, a 1,000,000e18 loan closes after the borrower pays only 1.5e18 (V1), and the LP redeems exactly 1.5e18 of yield, 1/2000 of the advertised 3000e18 (V2). (3) At 20 WETH the fee is 6e18 on 100e18, and a borrower who pays the advertised 0.3e18 reverts with ThunderLoan__NotPaidBack. (4) At 400 WETH the fee is 120e18, and the fee crosses the principal between 333 and 334 WETH. (5) A fuzz test confirms fee/advertised = price/1e18 for prices from 1e14 to 1e21, and a separate test shows that a 6-decimal and an 18-decimal token with the same quote get the same raw-unit fee. Run it with forge test --match-path test/PoC_TL-07.t.sol -vv.

File test/PoC_TL-07.t.sol; run FOUNDRY_FFI=false forge test --match-path test/PoC_TL-07.t.sol -vv.

// SPDX-License-Identifier: MIT
pragma solidity 0.8.20;
​
import { Test, console } from "forge-std/Test.sol";
import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import { ERC20 } from "@openzeppelin/contracts/token/ERC20/ERC20.sol";
import { ERC20Mock } from "@openzeppelin/contracts/mocks/ERC20Mock.sol";
import { ERC1967Proxy } from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol";
​
import { ThunderLoan } from "../src/protocol/ThunderLoan.sol";
import { ThunderLoanUpgraded } from "../src/upgradedProtocol/ThunderLoanUpgraded.sol";
import { AssetToken } from "../src/protocol/AssetToken.sol";
​
/// TSwap pool stand-in whose WETH price is configurable (the repo's MockTSwapPool
/// hardcodes 1e18, the only price at which the fee formula happens to be right).
contract PricedPool {
uint256 public price;
​
constructor(uint256 p) {
price = p;
}
​
function getPriceOfOnePoolTokenInWeth() external view returns (uint256) {
return price;
}
}
​
/// Pool factory stand-in: token -> pool.
contract PricedFactory {
mapping(address => address) private s_pools;
​
function setPool(address token, uint256 price) external returns (address pool) {
pool = address(new PricedPool(price));
s_pools[token] = pool;
}
​
function getPool(address token) external view returns (address) {
return s_pools[token];
}
}
​
/// 6-decimal ERC20 (USDC/USDT-like).
contract Token6 is ERC20 {
constructor() ERC20("Six", "SIX") { }
​
function decimals() public pure override returns (uint8) {
return 6;
}
​
function mint(address to, uint256 amt) external {
_mint(to, amt);
}
}
​
interface ITL {
function flashloan(address receiverAddress, IERC20 token, uint256 amount, bytes calldata params) external;
function repay(IERC20 token, uint256 amount) external;
function deposit(IERC20 token, uint256 amount) external;
function redeem(IERC20 token, uint256 amountOfAssetToken) external;
function getAssetFromToken(IERC20 token) external view returns (AssetToken);
function getCalculatedFee(IERC20 token, uint256 amount) external view returns (uint256);
}
​
/// Honest borrower: repays EVERYTHING it holds (principal + whatever it was funded
/// with for the fee) through the protocol's own repay(). Records the fee it was quoted.
contract RepayAllReceiver {
uint256 public quotedFee;
​
function executeOperation(
address token,
uint256, /* amount */
uint256 fee,
address, /* initiator */
bytes calldata /* params */
)
external
returns (bool)
{
quotedFee = fee;
uint256 bal = IERC20(token).balanceOf(address(this));
IERC20(token).approve(msg.sender, bal);
ITL(msg.sender).repay(IERC20(token), bal);
return true;
}
}
​
contract PoC_TL07 is Test {
PricedFactory factory;
ThunderLoan v1;
ThunderLoanUpgraded v2;
​
ERC20Mock tPeg; // priced exactly 1 WETH (what the repo's mock assumes)
ERC20Mock tCheap; // priced 5e14 WETH (~ a stablecoin)
ERC20Mock tPricey; // priced 20 WETH
ERC20Mock tVeryPricey; // priced 400 WETH
​
address lp = makeAddr("lp");
​
uint256 constant ADVERTISED_FEE = 3e15; // 0.3% (s_flashLoanFee)
uint256 constant PRECISION = 1e18;
​
function setUp() public {
factory = new PricedFactory();
tPeg = new ERC20Mock();
tCheap = new ERC20Mock();
tPricey = new ERC20Mock();
tVeryPricey = new ERC20Mock();
factory.setPool(address(tPeg), 1e18);
factory.setPool(address(tCheap), 5e14);
factory.setPool(address(tPricey), 20e18);
factory.setPool(address(tVeryPricey), 400e18);
​
v1 = ThunderLoan(address(new ERC1967Proxy(address(new ThunderLoan()), "")));
v1.initialize(address(factory));
v2 = ThunderLoanUpgraded(address(new ERC1967Proxy(address(new ThunderLoanUpgraded()), "")));
v2.initialize(address(factory));
​
ERC20Mock[4] memory toks = [tPeg, tCheap, tPricey, tVeryPricey];
for (uint256 i; i < 4; ++i) {
v1.setAllowedToken(IERC20(address(toks[i])), true);
v2.setAllowedToken(IERC20(address(toks[i])), true);
}
}
​
function _advertised(uint256 amount) internal pure returns (uint256) {
return (amount * ADVERTISED_FEE) / PRECISION;
}
​
function _lpDeposit(ITL tl, ERC20Mock tok, uint256 amt) internal {
tok.mint(lp, amt);
vm.startPrank(lp);
tok.approve(address(tl), amt);
tl.deposit(IERC20(address(tok)), amt);
vm.stopPrank();
}
​
// ---------------------------------------------------------------------
// BASELINE: at price == 1e18 the fee is exactly the advertised 0.3%.
// ---------------------------------------------------------------------
function test_Baseline_PricedAtOneWeth_FeeIsAdvertised() public {
uint256 amount = 1000e18;
assertEq(v1.getCalculatedFee(IERC20(address(tPeg)), amount), 3e18, "V1: 0.3% at price 1e18");
assertEq(v2.getCalculatedFee(IERC20(address(tPeg)), amount), 3e18, "V2: 0.3% at price 1e18");
​
_lpDeposit(ITL(address(v2)), tPeg, amount);
AssetToken at = v2.getAssetFromToken(IERC20(address(tPeg)));
RepayAllReceiver r = new RepayAllReceiver();
tPeg.mint(address(r), 3e18);
uint256 before = tPeg.balanceOf(address(at));
v2.flashloan(address(r), IERC20(address(tPeg)), amount, "");
assertEq(tPeg.balanceOf(address(at)) - before, 3e18, "pool earned exactly 0.3%");
console.log("[baseline] price 1e18, loan 1000e18 -> fee", r.quotedFee());
}
​
// ---------------------------------------------------------------------
// LIMB 1 (V1, the live contract): token priced 5e14 WETH. A 1,000,000-token
// loan is charged 1.5 tokens instead of 3,000. The borrower brings only 1.5
// tokens and the loan closes successfully.
// ---------------------------------------------------------------------
function test_CheapToken_V1_BorrowerPays1_5InsteadOf3000() public {
uint256 amount = 1_000_000e18;
_lpDeposit(ITL(address(v1)), tCheap, amount);
AssetToken at = v1.getAssetFromToken(IERC20(address(tCheap)));
​
uint256 fee = v1.getCalculatedFee(IERC20(address(tCheap)), amount);
uint256 advertised = _advertised(amount);
assertEq(fee, 1.5e18, "fee charged = 1.5 tokens");
assertEq(advertised, 3000e18, "advertised 0.3% = 3000 tokens");
assertEq(advertised / fee, 2000, "charged 1/2000 of the advertised fee");
​
RepayAllReceiver r = new RepayAllReceiver();
tCheap.mint(address(r), 1.5e18); // borrower brings ONLY 1.5 tokens
uint256 before = tCheap.balanceOf(address(at));
v1.flashloan(address(r), IERC20(address(tCheap)), amount, "");
uint256 earned = tCheap.balanceOf(address(at)) - before;
​
assertEq(r.quotedFee(), 1.5e18, "receiver was quoted 1.5 tokens");
assertEq(earned, 1.5e18, "pool collected 1.5 tokens on a 1,000,000-token loan");
assertEq(tCheap.balanceOf(address(r)), 0, "borrower spent only 1.5 tokens");
console.log("[V1 cheap] fee charged (wei) :", earned);
console.log("[V1 cheap] advertised 0.3% (wei):", advertised);
}
​
// ---------------------------------------------------------------------
// LIMB 1 (Upgraded): the LP's realised yield is 1/2000 of the advertised one.
// ---------------------------------------------------------------------
function test_CheapToken_V2_LPYieldIsOne2000thOfAdvertised() public {
uint256 amount = 1_000_000e18;
_lpDeposit(ITL(address(v2)), tCheap, amount);
​
RepayAllReceiver r = new RepayAllReceiver();
tCheap.mint(address(r), 1.5e18);
v2.flashloan(address(r), IERC20(address(tCheap)), amount, "");
​
AssetToken at = v2.getAssetFromToken(IERC20(address(tCheap)));
uint256 shares = at.balanceOf(lp);
vm.prank(lp);
v2.redeem(IERC20(address(tCheap)), shares);
uint256 lpYield = tCheap.balanceOf(lp) - amount;
​
assertEq(lpYield, 1.5e18, "LP earned 1.5 tokens");
assertEq(_advertised(amount) / lpYield, 2000, "LP earned 1/2000 of the advertised 0.3%");
console.log("[V2 cheap] LP yield (wei) :", lpYield);
console.log("[V2 cheap] advertised LP yield (wei) :", _advertised(amount));
}
​
// ---------------------------------------------------------------------
// LIMB 2: token priced 20 WETH -> 6% fee. A borrower paying the advertised
// 0.3% is reverted; only paying 6% (20x) closes the loan.
// ---------------------------------------------------------------------
function test_PriceyToken_V2_SixPercentFee() public {
uint256 amount = 100e18;
_lpDeposit(ITL(address(v2)), tPricey, amount);
​
uint256 fee = v2.getCalculatedFee(IERC20(address(tPricey)), amount);
assertEq(fee, 6e18, "fee = 6 tokens on 100 = 6%");
assertEq(v1.getCalculatedFee(IERC20(address(tPricey)), amount), 6e18, "V1 identical");
assertEq(fee / _advertised(amount), 20, "20x the advertised 0.3%");
​
// Borrower bringing the advertised 0.3% (0.3 tokens) is reverted.
RepayAllReceiver r1 = new RepayAllReceiver();
tPricey.mint(address(r1), _advertised(amount));
vm.expectRevert(
abi.encodeWithSelector(
ThunderLoanUpgraded.ThunderLoan__NotPaidBack.selector, amount + 6e18, amount + _advertised(amount)
)
);
v2.flashloan(address(r1), IERC20(address(tPricey)), amount, "");
​
// Only paying 6% succeeds.
RepayAllReceiver r2 = new RepayAllReceiver();
tPricey.mint(address(r2), 6e18);
AssetToken at = v2.getAssetFromToken(IERC20(address(tPricey)));
uint256 before = tPricey.balanceOf(address(at));
v2.flashloan(address(r2), IERC20(address(tPricey)), amount, "");
assertEq(tPricey.balanceOf(address(at)) - before, 6e18, "borrower paid 6% of principal");
console.log("[V2 pricey 20 WETH] fee on 100e18 :", fee);
}
​
// ---------------------------------------------------------------------
// LIMB 2 (extreme): above ~333.3 WETH/token the fee exceeds the principal.
// ---------------------------------------------------------------------
function test_VeryPriceyToken_FeeExceedsPrincipal() public {
uint256 amount = 100e18;
uint256 fee1 = v1.getCalculatedFee(IERC20(address(tVeryPricey)), amount);
uint256 fee2 = v2.getCalculatedFee(IERC20(address(tVeryPricey)), amount);
assertEq(fee1, 120e18, "V1: 120% fee at 400 WETH");
assertEq(fee2, 120e18, "V2: 120% fee at 400 WETH");
assertGt(fee1, amount, "fee > principal");
​
// Threshold check using the same formula the contract uses.
PricedFactory f = factory;
ERC20Mock t333 = new ERC20Mock();
ERC20Mock t334 = new ERC20Mock();
f.setPool(address(t333), 333e18);
f.setPool(address(t334), 334e18);
assertLt(v2.getCalculatedFee(IERC20(address(t333)), amount), amount, "333 WETH: fee < principal");
assertGt(v2.getCalculatedFee(IERC20(address(t334)), amount), amount, "334 WETH: fee > principal");
​
// And a loan of it actually requires paying 120% on top of principal.
_lpDeposit(ITL(address(v2)), tVeryPricey, amount);
RepayAllReceiver r = new RepayAllReceiver();
tVeryPricey.mint(address(r), 120e18);
AssetToken at = v2.getAssetFromToken(IERC20(address(tVeryPricey)));
v2.flashloan(address(r), IERC20(address(tVeryPricey)), amount, "");
assertEq(tVeryPricey.balanceOf(address(at)), amount + 120e18, "borrower paid 120% fee");
console.log("[400 WETH] fee on 100e18 :", fee2);
}
​
// ---------------------------------------------------------------------
// GENERALISATION: effective token-denominated rate = 0.3% * price / 1e18,
// for any price; correct only at price == 1e18.
// ---------------------------------------------------------------------
function testFuzz_FeeRateScalesWithWethPrice(uint256 price, uint256 amount) public {
price = bound(price, 1e14, 1e21);
amount = bound(amount, 1e20, 1e27);
ERC20Mock t = new ERC20Mock();
factory.setPool(address(t), price);
v2.setAllowedToken(IERC20(address(t)), true);
​
uint256 fee = v2.getCalculatedFee(IERC20(address(t)), amount);
uint256 adv = _advertised(amount);
// fee / advertised == price / 1e18 (up to rounding)
assertApproxEqRel(fee * 1e18, adv * price, 1e12, "fee/advertised == price/1e18");
}
​
// ---------------------------------------------------------------------
// LIMB 3 (code fact, convention-independent): decimals() are never consulted.
// A 6-decimal and an 18-decimal token with the same price quote get the
// identical raw-unit fee for the same raw amount.
// ---------------------------------------------------------------------
function test_DecimalsNeverNormalised() public {
Token6 usdcLike = new Token6();
ERC20Mock t18 = new ERC20Mock();
uint256 quote = 3e14; // hypothetical quote; TSwap's real convention is out of scope
factory.setPool(address(usdcLike), quote);
factory.setPool(address(t18), quote);
v2.setAllowedToken(IERC20(address(usdcLike)), true);
​
uint256 raw = 1_000_000e6;
assertEq(
v2.getCalculatedFee(IERC20(address(usdcLike)), raw),
v2.getCalculatedFee(IERC20(address(t18)), raw),
"fee independent of decimals()"
);
// Under a per-whole-token quote of 3e14 WETH, a 1,000,000 USDC loan is
// charged 900_000 raw units = 0.9 USDC instead of 3,000 USDC.
assertEq(v2.getCalculatedFee(IERC20(address(usdcLike)), raw), 900_000, "0.9 USDC");
console.log("[6-dec, assumed quote 3e14] fee raw units on 1,000,000 USDC:", uint256(900_000));
console.log("[6-dec] advertised 0.3% raw units :", _advertised(raw));
}
}

Recommended Mitigation

--- a/src/protocol/ThunderLoan.sol
+++ b/src/protocol/ThunderLoan.sol
@@ -245,9 +245,7 @@
- function getCalculatedFee(IERC20 token, uint256 amount) public view returns (uint256 fee) {
- //slither-disable-next-line divide-before-multiply
- uint256 valueOfBorrowedToken = (amount * getPriceInWeth(address(token))) / s_feePrecision;
- //slither-disable-next-line divide-before-multiply
- fee = (valueOfBorrowedToken * s_flashLoanFee) / s_feePrecision;
+ function getCalculatedFee(IERC20 /* token */, uint256 amount) public view returns (uint256 fee) {
+ // The fee is collected and credited in borrowed-token units, so compute it in those units.
+ fee = (amount * s_flashLoanFee) / s_feePrecision;
}
function updateFlashLoanFee(uint256 newFee) external onlyOwner {
--- a/src/upgradedProtocol/ThunderLoanUpgraded.sol
+++ b/src/upgradedProtocol/ThunderLoanUpgraded.sol
@@ -243,9 +243,7 @@
- function getCalculatedFee(IERC20 token, uint256 amount) public view returns (uint256 fee) {
- //slither-disable-next-line divide-before-multiply
- uint256 valueOfBorrowedToken = (amount * getPriceInWeth(address(token))) / FEE_PRECISION;
- //slither-disable-next-line divide-before-multiply
- fee = (valueOfBorrowedToken * s_flashLoanFee) / FEE_PRECISION;
+ function getCalculatedFee(IERC20 /* token */, uint256 amount) public view returns (uint256 fee) {
+ // The fee is collected and credited in borrowed-token units, so compute it in those units.
+ fee = (amount * s_flashLoanFee) / FEE_PRECISION;
}
function updateFlashLoanFee(uint256 newFee) external onlyOwner {
Updates

Lead Judging Commences

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