Thunder Loan

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

Fee-on-transfer tokens (in-scope: USDT/STA/PAXG) make flash loans permanently revert

Fee-on-transfer tokens (in-scope: USDT/STA/PAXG) make flash loans permanently revert

Severity: Medium · Impact: Medium · Likelihood: Medium

Description

  • The token compatibility list explicitly includes fee-on-transfer tokens (STA, PAXG, and USDT which can enable a transfer fee). flashloan() measures repayment purely by the AssetToken's balance: it records startingBalance and requires endingBalance >= startingBalance + fee.

  • When the borrowed token charges a transfer fee, a borrower who correctly sends amount + fee via repay() causes the AssetToken to receive less than that (the fee is deducted in transit), so endingBalance never reaches startingBalance + fee and the loan reverts.

function flashloan(address receiverAddress, IERC20 token, uint256 amount, bytes calldata params) external {
uint256 startingBalance = IERC20(token).balanceOf(address(assetToken));
...
uint256 endingBalance = token.balanceOf(address(assetToken));
@> if (endingBalance < startingBalance + fee) { // fee-on-transfer delivers less -> always true
revert ThunderLoan__NotPaidBack(startingBalance + fee, endingBalance);
}
}

Risk

Likelihood:

  • Occurs for every flash loan of an in-scope fee-on-transfer token, even when the borrower behaves honestly and repays the full amount + fee.

Impact:

  • Flash loans of these supported tokens are impossible (a core feature is bricked for a whole class of in-scope assets). The same undercounting also corrupts deposit() accounting, so LP balances for such tokens overstate the real holdings.

Proof of Concept

Save the block below as test/PocM2.t.sol and run forge test --mt test_flashloan_reverts_for_fee_on_transfer_token -vv. An honest flash loan that repays amount + fee reverts because the token deducts a transfer fee.

// SPDX-License-Identifier: MIT
pragma solidity 0.8.20;
import { Test } from "forge-std/Test.sol";
import { ThunderLoan } from "../src/protocol/ThunderLoan.sol";
import { ERC20 } from "@openzeppelin/contracts/token/ERC20/ERC20.sol";
import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import { MockPoolFactory } from "./mocks/MockPoolFactory.sol";
import { ERC1967Proxy } from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol";
contract FeeToken is ERC20 {
constructor() ERC20("Fee", "FEE") { }
function mint(address to, uint256 amt) external { _mint(to, amt); }
function _transfer(address from, address to, uint256 amt) internal override {
uint256 fee = amt / 10; // 10% fee-on-transfer
super._transfer(from, to, amt - fee);
super._burn(from, fee);
}
}
contract PocM2 is Test {
ThunderLoan thunderLoan;
FeeToken token;
address lp = makeAddr("lp");
function setUp() public {
ThunderLoan impl = new ThunderLoan();
MockPoolFactory factory = new MockPoolFactory();
token = new FeeToken();
factory.createPool(address(token));
ERC1967Proxy proxy = new ERC1967Proxy(address(impl), "");
thunderLoan = ThunderLoan(address(proxy));
thunderLoan.initialize(address(factory));
thunderLoan.setAllowedToken(IERC20(address(token)), true);
token.mint(lp, 2000e18);
vm.startPrank(lp);
token.approve(address(thunderLoan), 2000e18);
thunderLoan.deposit(IERC20(address(token)), 1000e18);
vm.stopPrank();
}
function test_flashloan_reverts_for_fee_on_transfer_token() public {
Receiver receiver = new Receiver(thunderLoan, token);
token.mint(address(receiver), 100e18); // plenty to cover amount + fee
vm.expectRevert(); // ThunderLoan__NotPaidBack
receiver.go(100e18);
}
}
contract Receiver {
ThunderLoan thunderLoan;
FeeToken token;
constructor(ThunderLoan _tl, FeeToken _t) { thunderLoan = _tl; token = _t; }
function go(uint256 amount) external {
thunderLoan.flashloan(address(this), IERC20(address(token)), amount, "");
}
function executeOperation(address _token, uint256 amount, uint256 fee, address, bytes calldata)
external returns (bool)
{
uint256 owed = amount + fee; // honest full repayment
IERC20(_token).approve(address(thunderLoan), owed);
thunderLoan.repay(IERC20(_token), owed);
return true;
}
}

Recommended Mitigation

Do not assume the token is standard. Either (a) restrict the protocol to a vetted allow-list of non-fee, non-rebasing tokens and document it, or (b) make the accounting balance-based so it tolerates transfer fees — measure the AssetToken's real balance before and after and require the delta to cover the fee, rather than assuming amount + fee arrives intact.

function flashloan(address receiverAddress, IERC20 token, uint256 amount, bytes calldata params) external {
AssetToken assetToken = s_tokenToAssetToken[token];
uint256 startingBalance = IERC20(token).balanceOf(address(assetToken));
...
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) {
+ // require the ACTUAL received amount (endingBalance - startingBalance) to cover the fee,
+ // and additionally reject tokens whose balance delta on a known transfer != amount sent.
+ if (endingBalance < startingBalance + fee) {
revert ThunderLoan__NotPaidBack(startingBalance + fee, endingBalance);
}

Prefer approach (a) — a strict allow-list — as the robust fix, since balance-delta accounting still cannot make fee-on-transfer economics work for the borrower.

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!