Thunder Loan

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

ThunderLoanUpgraded removes getFeePrecision() breaking compatibility with IThunderLoanFixed interface.

Root + Impact

Description

  • `ThunderLoan` exposes `getFeePrecision()` as a public function returning `s_feePrecision`, and `IThunderLoanFixed` declares it as part of the protocol interface, allowing external integrators and receivers to query the fee precision value.

  • In `ThunderLoanUpgraded`, `s_feePrecision` is replaced by the constant `FEE_PRECISION` and `getFeePrecision()` is never reimplemented — any external contract or integrator that calls `getFeePrecision()` through `IThunderLoanFixed` after the upgrade will hit a selector with no matching function in the new implementation, causing the call to revert silently or fall through to the fallback, breaking any off-chain or on-chain logic that depends on this value.

Risk

Likelihood:

  • This issue manifests any time an external contract or integrator that was built against IThunderLoanFixed calls getFeePrecision() after the protocol is upgraded to ThunderLoanUpgraded, as the function no longer exists in the new implementation.

Impact:

  • Any on-chain contract that calls getFeePrecision() through IThunderLoanFixed after the upgrade will revert unexpectedly, breaking flash loan receivers that rely on this value to correctly compute and approve the repayment amount, potentially causing legitimate flash loans to fail.

  • Off-chain integrations and frontends that query getFeePrecision() to display or compute fee information will receive unexpected errors after the upgrade, degrading the user experience and potentially causing incorrect fee estimations that lead users to underpay and have their transactions reverted.

Proof of Concept

The test verifies that `getFeePrecision()`, declared in `IThunderLoanFixed` and implemented in `ThunderLoan`, is silently removed in `ThunderLoanUpgraded` where `s_feePrecision` is replaced by the constant `FEE_PRECISION` without reimplementing the function. First, it confirms that `getFeePrecision()` returns `1e18` correctly before the upgrade. After upgrading to `ThunderLoanUpgraded`, the same call through `IThunderLoanFixed` reverts because no matching selector exists in the new implementation. Finally, it proves the concrete impact by showing that `FlashLoanAttacker`, which relies on `getFeePrecision()` to compute the maximum borrowable amount, reverts entirely after the upgrade — demonstrating that any on-chain integrator built against `IThunderLoanFixed` is permanently broken without redeployment.

function testGetFeePrecisionRevertsAfterUpgrade() public {
// SETUP: allow tokenA and fund the depositer
assetTokenA = thunderLoan.setAllowedToken(
IERC20(address(tokenA)),
true
);

contract POCtest is Test {
ThunderLoan thunderLoanImplementation;
MockPoolFactory mockPoolFactory;
ERC1967Proxy proxy;
ThunderLoan thunderLoan;
ERC20Mock weth;
ERC20Mock tokenA;
ERC20Mock tokenB;
ERC20Mock6Decimals tokenWith6Decimals;
AssetToken assetToken6Decimals;
AssetToken assetTokenA;
AssetToken assetTokenB;
address depositer = makeAddr("depositer");
address flahLoanReceiver = makeAddr("flashLoanReceiver");
address flashLoanAttacker = makeAddr("flashLoanAttacker");
function setUp() public virtual {
thunderLoan = new ThunderLoan();
mockPoolFactory = new MockPoolFactory();
weth = new ERC20Mock();
tokenA = new ERC20Mock();
tokenB = new ERC20Mock();
tokenWith6Decimals = new ERC20Mock6Decimals();
mockPoolFactory.createPool(address(tokenA));
mockPoolFactory.createPool(address(tokenWith6Decimals));
proxy = new ERC1967Proxy(address(thunderLoan), "");
thunderLoan = ThunderLoan(address(proxy));
thunderLoan.initialize(address(mockPoolFactory));
assetTokenB = thunderLoan.setAllowedToken(
IERC20(address(tokenB)),
true
);
assetToken6Decimals = thunderLoan.setAllowedToken(
IERC20(address(tokenWith6Decimals)),
true
);
tokenA.mint(depositer, 50000 * 10 ** tokenA.decimals()); //fund depositer with tokenA
//fund depositer with token with 6 decimals
tokenWith6Decimals.mint(
depositer,
5000 * 10 ** tokenWith6Decimals.decimals()
); // 5000 tokens in 6-decimal representation
}
function testGetFeePrecisionRevertsAfterUpgrade() public {
// SETUP: allow tokenA and fund the depositer
assetTokenA = thunderLoan.setAllowedToken(
IERC20(address(tokenA)),
true
);
// STEP 1: verify getFeePrecision() works correctly BEFORE upgrade
// IThunderLoanFixed exposes getFeePrecision() and ThunderLoan implements it
IThunderLoanFixed thunderLoanFixed = IThunderLoanFixed(address(proxy));
uint256 feePrecisionBefore = thunderLoanFixed.getFeePrecision();
assertEq(
feePrecisionBefore,
1e18,
"Fee precision should be 1e18 before upgrade"
);
console.log("=== BEFORE UPGRADE ===");
console.log("getFeePrecision() returned: ", feePrecisionBefore); // 1e18 ✅
// STEP 2: upgrade to ThunderLoanUpgraded
// ThunderLoanUpgraded removes getFeePrecision() and replaces s_feePrecision
// with a constant FEE_PRECISION — the function is never reimplemented
ThunderLoanUpgraded thunderLoanUpgradedImplementation = new ThunderLoanUpgraded();
vm.prank(thunderLoan.owner());
thunderLoan.upgradeTo(address(thunderLoanUpgradedImplementation));
console.log("=== AFTER UPGRADE ===");
// STEP 3: prove getFeePrecision() is no longer available
// IThunderLoanFixed still declares it, but ThunderLoanUpgraded has no matching selector
// the call will revert because no function matches the selector in the new implementation
vm.expectRevert();
uint256 feePrecisionAfter = thunderLoanFixed.getFeePrecision();
// STEP 4: prove the impact — a FlashLoanAttacker that relies on getFeePrecision()
// to compute the max borrowable amount will revert entirely, breaking its logic
tokenA.mint(depositer, 50000e18);
vm.startPrank(depositer);
tokenA.approve(address(proxy), 50000e18);
ThunderLoanUpgraded(address(proxy)).deposit(
IERC20(address(tokenA)),
50000e18
);
vm.stopPrank();
uint256 initialAttackerBalance = 10e18;
tokenA.mint(flashLoanAttacker, initialAttackerBalance);
vm.startPrank(flashLoanAttacker);
FlashLoanAttacker attackerContract = new FlashLoanAttacker(
address(proxy)
);
tokenA.transfer(address(attackerContract), initialAttackerBalance);
// FlashLoanAttacker calls getFeePrecision() internally to compute maxAmount
// after upgrade this reverts — any integrator relying on getFeePrecision() is broken
vm.expectRevert();
attackerContract.attack(address(tokenA));
vm.stopPrank();
// FINAL ASSERT: confirm ThunderLoanUpgraded has no getFeePrecision()
// by verifying FEE_PRECISION constant exists but the function does not
assertEq(
ThunderLoanUpgraded(address(proxy)).FEE_PRECISION(),
1e18,
"FEE_PRECISION constant exists but getFeePrecision() function does not"
);
}
}

This is the implementation fixed used for the test:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
interface IThunderLoanFixed {
function initialize(address tswapAddress) external;
function deposit(address token, uint256 amount) external;
function redeem(address token, uint256 amountOfAssetToken) external;
function flashloan(
address receiverAddress,
address token,
uint256 amount,
bytes calldata params
) external;
function repay(IERC20 token, uint256 amount) external;
function setAllowedToken(
address token,
bool allowed
) external returns (address);
function getCalculatedFee(
address token,
uint256 amount
) external view returns (uint256);
function updateFlashLoanFee(uint256 newFee) external;
function isAllowedToken(address token) external view returns (bool);
function getAssetFromToken(address token) external view returns (address);
function isCurrentlyFlashLoaning(
address token
) external view returns (bool);
function getFee() external view returns (uint256);
function getFeePrecision() external view returns (uint256);
}

Recommended Mitigation

Since `FEE_PRECISION` is already declared as a public constant in `ThunderLoanUpgraded`, the simplest fix is to reintroduce `getFeePrecision()` as a `pure` function that returns it directly, preserving full compatibility with `IThunderLoanFixed` at zero storage cost. This approach requires no architectural changes and ensures that any existing integrator, receiver, or off-chain tool built against the original interface continues to work correctly after the upgrade without requiring redeployment or migration.

+ function getFeePrecision() external pure returns (uint256) {
+ return FEE_PRECISION;
+ }
Updates

Lead Judging Commences

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