Thunder Loan

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

Fee-on-transfer tokens cause silent under-repayment, allowing flash loans to be repaid with less than owed

Root + Impact

Description

The issue

The balance check compares absolute balances, not transfer amounts. For fee-on-transfer tokens (USDT variant, STA, PAXG — all explicitly in scope), a transfer of amount + fee does not result in the recipient receiving exactly amount + fee. A transfer tax is deducted in-flight, so address(assetToken) receives (amount + fee) * (1 - tax). The balance check passes if the resulting balance still exceeds startingBalance + fee, which depends on pool depth — but in all cases the protocol collects less fee than intended.

function flashloan(...) external {
// ...
uint256 startingBalance = IERC20(token).balanceOf(address(assetToken));
// ...
receiverAddress.functionCall(...); // repay() called inside callback
uint256 endingBalance = token.balanceOf(address(assetToken));
// @> Checks absolute balance — does not account for tokens lost to transfer tax
// @> For fee-on-transfer tokens, endingBalance < (amount_sent + startingBalance)
if (endingBalance < startingBalance + fee) {
revert ThunderLoan__NotPaidBack(startingBalance + fee, endingBalance);
}
}
// repay() uses safeTransferFrom — transfers nominal amount, tax deducted in token contract
function repay(IERC20 token, uint256 amount) public {
if (!s_currentlyFlashLoaning[token]) {
revert ThunderLoan__NotCurrentlyFlashLoaning();
}
AssetToken assetToken = s_tokenToAssetToken[IERC20(token)];
// @> Nominal amount transferred, but assetToken receives less due to transfer tax
token.safeTransferFrom(msg.sender, address(assetToken), amount);
}

Risk

Likelihood:

  • Guaranteed to occur on every flash loan of STA (1% transfer tax) or PAXG (0.02% transfer tax) — both explicitly listed in scope

  • No attacker required — this is a passive accounting error on every legitimate use

Impact:

  • Protocol systematically collects less fee than declared, reducing LP yield on every flash loan of affected tokens

  • Over time the shortfall compounds — LPs believe they are earning 0.3% but receive less

  • With sufficiently large loans or high transfer taxes (STA at 1%), the fee shortfall can exceed the entire declared fee, making flash loans effectively free

Proof of Concept

function testFeeOnTransferUnderRepayment() public {
// Setup: STA token with 1% transfer tax
MockFeeOnTransferToken sta = new MockFeeOnTransferToken(100); // 1% = 100 bps
thunderLoan.setAllowedToken(IERC20(address(sta)), true);
// LP deposits 10,000 STA
// (actual deposited after tax: 9,900 STA)
sta.mint(address(lp), 10_100e18);
vm.startPrank(lp);
sta.approve(address(thunderLoan), 10_100e18);
thunderLoan.deposit(IERC20(address(sta)), 10_000e18);
vm.stopPrank();
AssetToken assetToken = thunderLoan.getAssetFromToken(IERC20(address(sta)));
uint256 poolBalanceBefore = sta.balanceOf(address(assetToken));
// Borrower takes flash loan of 1,000 STA
uint256 fee = thunderLoan.getCalculatedFee(IERC20(address(sta)), 1_000e18);
// Borrower tries to repay 1,000 + fee STA
// After 1% tax: assetToken receives (1000 + fee) * 0.99
// Flash loan completes — balance check passes if pool is large enough
// But protocol received LESS than fee — LP yield is silently reduced
uint256 poolBalanceAfter = sta.balanceOf(address(assetToken));
uint256 actualFeeCollected = poolBalanceAfter - poolBalanceBefore + 1_000e18;
// actualFeeCollected < fee — protocol collected less than it should
assertLt(actualFeeCollected, fee);
}

Recommended Mitigation

Use a balance-delta pattern in repay() to measure what was actually received rather than trusting the nominal transfer amount:

function repay(IERC20 token, uint256 amount) public {
if (!s_currentlyFlashLoaning[token]) {
revert ThunderLoan__NotCurrentlyFlashLoaning();
}
AssetToken assetToken = s_tokenToAssetToken[IERC20(token)];
// @> Measure actual received amount for fee-on-transfer token safety
uint256 balanceBefore = token.balanceOf(address(assetToken));
token.safeTransferFrom(msg.sender, address(assetToken), amount);
uint256 actualReceived = token.balanceOf(address(assetToken)) - balanceBefore;
// Store actualReceived and use it in the flashloan() balance check
// instead of the nominal startingBalance + fee
s_amountRepaid[token] += actualReceived;
}
Updates

Lead Judging Commences

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