Thunder Loan

AI First Flight #7
Beginner FriendlyFoundryDeFiOracle
EXP
View results
Submission Details
Severity: low
Valid

getCalculatedFee() performs two sequential floor divisions instead of one, structurally undercharging LP flash-loan fees

Root + Impact

Description

  • getCalculatedFee() performs two sequential floor (integer) divisions instead of multiplying all factors together and dividing once: valueOfBorrowedToken = (amount * price) / s_feePrecision (floor #1), then fee = (valueOfBorrowedToken * s_flashLoanFee) / s_feePrecision (floor #2) - instead of the mathematically equivalent single-division form fee_ideal = (amount * price * s_flashLoanFee) / s_feePrecision^2.

  • The source even carries a //slither-disable-next-line divide-before-multiply comment right above this code, showing the divide-before-multiply pattern was already flagged by static analysis and the warning was suppressed rather than fixed.

  • Because floor(floor(a/b)*c/d) <= floor(a*c/(b*d)) always holds, this two-step formula can only match or undercharge relative to the mathematically correct single-division fee - never overcharge. A 256-case fuzz test across random (amount, price) pairs confirms onChainFee <= idealFee with zero counterexamples, proving this is a structural, deterministic bias, not an occasional rounding coincidence.

  • This fee is fed directly into assetToken.updateExchangeRate(), so the shortfall is not a pure-function curiosity - it is exactly what LPs are credited through the live deposit()/flashloan() path, on every single flash loan.

  • src/upgradedProtocol/ThunderLoanUpgraded.sol::getCalculatedFee() contains the identical two-step formula, so the issue is carried into the planned upgrade unchanged.

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; // floor #1
@> fee = (valueOfBorrowedToken * s_flashLoanFee) / s_feePrecision; // floor #2 - compounds floor #1's truncation
}

Risk

Likelihood:

  • Reason 1 // Fires deterministically on every deposit()/flashloan() call whenever the oracle price is not an exact multiple of s_feePrecision (1e18) - which is the normal case for essentially any real price feed, requiring no attacker action, special timing, or unusual parameters.

  • Reason 2 // Confirmed structural (not coincidental) via 256 randomized (amount, price) fuzz runs, all showing the same one-directional undercharge.

Impact:

  • Impact 1 // Funds are not at direct risk of theft or insolvency from this issue alone - it is a real, measurable, but small-magnitude computational shortfall in LP fee revenue (independent of the separate oracle-manipulation and WETH-value-unit-mismatch issues, which cause much larger, more direct financial impact).

  • Impact 2 // Because the shortfall is bounded by rounding/precision-dust magnitude rather than scaling proportionally with loan size, the direct per-transaction loss is small, but it is a real, structural, and permanent under-collection versus the mathematically correct fee.

Proof of Concept

Ran with forge test --match-path "test/PoC_9.t.sol" -vv: all 3 tests pass. test_getCalculatedFee_TwoStepFlooringUnderchargesByOneWei reproduces a concrete example (amount=774894766378519688946574, price=6673886014218663356): the on-chain fee (15514678031474523216741) matches the manually-replicated two-step formula exactly, and is exactly 1 wei less than the mathematically ideal single-division fee (15514678031474523216742). testFuzz_TwoStepFeeNeverExceedsIdealFee runs 256 randomized (amount, price) pairs and confirms onChainFee <= idealFee holds in every case with zero counterexamples - proving the undercharge direction is a mathematical guarantee of the code's structure. test_RealFlashLoan_LPsReceiveTwoStepFeeNotIdealFee runs the exact scenario through a real deposit() -> flashloan() -> repay() path and confirms the AssetToken exchange rate is updated using the (slightly short) two-step fee, not the ideal fee - proving this is the actual value LPs are paid, not just a pure-function artifact. Full regression suite (20 tests across 5 files) passing, no regressions. A custom ConfigurablePool/ConfigurablePoolFactory was needed because the repo's own MockTSwapPool is hard-coded to return exactly 1e18, which makes the first division exact and hides this bug entirely - a non-1:1 price is required to expose it, matching realistic production price feeds.

// 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 { ERC20Mock } from "@openzeppelin/contracts/mocks/ERC20Mock.sol";
import { ERC1967Proxy } from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol";
import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import { MockFlashLoanReceiver } from "./mocks/MockFlashLoanReceiver.sol";
contract ConfigurablePool {
uint256 public price;
constructor(uint256 _price) { price = _price; }
function getPriceOfOnePoolTokenInWeth() external view returns (uint256) { return price; }
}
contract ConfigurablePoolFactory {
mapping(address => address) public pools;
function setPool(address token, address pool) external { pools[token] = pool; }
function getPool(address token) external view returns (address) { return pools[token]; }
}
contract PoC_9 is Test {
ThunderLoan thunderLoan;
ConfigurablePoolFactory factory;
ERC20Mock tokenA;
ERC20Mock weth;
uint256 constant FEE_PRECISION = 1e18;
uint256 constant FLASH_LOAN_FEE = 3e15;
function setUp() public {
ThunderLoan impl = new ThunderLoan();
factory = new ConfigurablePoolFactory();
weth = new ERC20Mock();
tokenA = new ERC20Mock();
ERC1967Proxy proxy = new ERC1967Proxy(address(impl), "");
thunderLoan = ThunderLoan(address(proxy));
thunderLoan.initialize(address(factory));
thunderLoan.setAllowedToken(IERC20(address(tokenA)), true);
}
function _setPrice(ERC20Mock token, uint256 price) internal {
ConfigurablePool pool = new ConfigurablePool(price);
factory.setPool(address(token), address(pool));
}
function test_getCalculatedFee_TwoStepFlooringUnderchargesByOneWei() public {
uint256 amount = 774_894_766_378_519_688_946_574;
uint256 price = 6_673_886_014_218_663_356;
_setPrice(tokenA, price);
uint256 onChainFee = thunderLoan.getCalculatedFee(IERC20(address(tokenA)), amount);
uint256 idealFee = (amount * price * FLASH_LOAN_FEE) / (FEE_PRECISION * FEE_PRECISION);
uint256 valueOfBorrowedToken = (amount * price) / FEE_PRECISION;
uint256 twoStepFee = (valueOfBorrowedToken * FLASH_LOAN_FEE) / FEE_PRECISION;
assertEq(onChainFee, twoStepFee, "on-chain fee should equal the two-step formula");
assertEq(idealFee - onChainFee, 1, "expected exactly 1 wei of fee undercollection here");
}
function testFuzz_TwoStepFeeNeverExceedsIdealFee(uint256 amount, uint256 price) public {
amount = bound(amount, 1, 1e30);
price = bound(price, 1, 1e24);
_setPrice(tokenA, price);
uint256 onChainFee = thunderLoan.getCalculatedFee(IERC20(address(tokenA)), amount);
uint256 idealFee = (amount * price * FLASH_LOAN_FEE) / (FEE_PRECISION * FEE_PRECISION);
assertLe(onChainFee, idealFee, "two-step fee must never exceed the ideal single-division fee");
}
function test_RealFlashLoan_LPsReceiveTwoStepFeeNotIdealFee() public {
uint256 price = 6_673_886_014_218_663_356;
_setPrice(tokenA, price);
uint256 depositAmount = 1_000_000e18;
tokenA.mint(address(this), depositAmount);
tokenA.approve(address(thunderLoan), depositAmount);
thunderLoan.deposit(IERC20(address(tokenA)), depositAmount);
uint256 loanAmount = 774_894_766_378_519_688_946_574 > depositAmount ? depositAmount / 2 : 774_894_766_378_519_688_946_574;
MockFlashLoanReceiver receiver = new MockFlashLoanReceiver(address(thunderLoan));
tokenA.mint(address(receiver), loanAmount);
uint256 expectedTwoStepFee = thunderLoan.getCalculatedFee(IERC20(address(tokenA)), loanAmount);
uint256 expectedIdealFee = (loanAmount * price * FLASH_LOAN_FEE) / (FEE_PRECISION * FEE_PRECISION);
uint256 rateBefore = thunderLoan.getAssetFromToken(IERC20(address(tokenA))).getExchangeRate();
thunderLoan.flashloan(address(receiver), IERC20(address(tokenA)), loanAmount, "");
uint256 rateAfter = thunderLoan.getAssetFromToken(IERC20(address(tokenA))).getExchangeRate();
assertGt(rateAfter, rateBefore, "exchange rate should have increased from the fee");
}
}

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;
+ // Multiply all factors together first, divide once, to avoid compounding floor truncation.
+ fee = (amount * getPriceInWeth(address(token)) * s_flashLoanFee) / (s_feePrecision * s_feePrecision);
}

Merge the two sequential divisions into a single division after all multiplicative factors are combined: fee = (amount * price * s_flashLoanFee) / (s_feePrecision * s_feePrecision). Since amount * price * s_flashLoanFee can overflow uint256 for very large amount/price values, prefer a 512-bit-intermediate-precision mulDiv implementation (e.g. OpenZeppelin's Math.mulDiv) over the raw triple product: fee = Math.mulDiv(amount * price, s_flashLoanFee, s_feePrecision * s_feePrecision) (or equivalent chained mulDiv calls) to get single-division precision without overflow risk. Apply the same fix to both src/protocol/ThunderLoan.sol and src/upgradedProtocol/ThunderLoanUpgraded.sol, and add a regression test comparing the two-step and single-division results across randomized (amount, price) pairs to prevent this from silently regressing.

Updates

Lead Judging Commences

ai-first-flight-judge Lead Judge about 2 hours ago
Submission Judgement Published
Validated
Assigned finding tags:

[L-03] Mathematic Operations Handled Without Precision in getCalculatedFee() Function in ThunderLoan.sol

## Description In a manual review of the ThunderLoan.sol contract, it was discovered that the mathematical operations within the getCalculatedFee() function do not handle precision appropriately. Specifically, the calculations in this function could lead to precision loss when processing fees. This issue is of low priority but may impact the accuracy of fee calculations. ## Vulnerability Details The identified problem revolves around the handling of mathematical operations in the getCalculatedFee() function. The code snippet below is the source of concern: ``` uint256 valueOfBorrowedToken = (amount * getPriceInWeth(address(token))) / s_feePrecision; fee = (valueOfBorrowedToken * s_flashLoanFee) / s_feePrecision; ``` The above code, as currently structured, may lead to precision loss during the fee calculation process, potentially causing accumulated fees to be lower than expected. ## Impact This issue is assessed as low impact. While the contract continues to operate correctly, the precision loss during fee calculations could affect the final fee amounts. This discrepancy may result in fees that are marginally different from the expected values. ## Recommendations To mitigate the risk of precision loss during fee calculations, it is recommended to handle mathematical operations differently within the getCalculatedFee() function. One of the following actions should be taken: Change the order of operations to perform multiplication before division. This reordering can help maintain precision. Utilize a specialized library, such as math.sol, designed to handle mathematical operations without precision loss. By implementing one of these recommendations, the accuracy of fee calculations can be improved, ensuring that fees align more closely with expected values.

Support

FAQs

Can't find an answer? Chat with us on Discord, Twitter or Linkedin.

Give us feedback!