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(...);
uint256 endingBalance = token.balanceOf(address(assetToken));
if (endingBalance < startingBalance + fee) {
revert ThunderLoan__NotPaidBack(startingBalance + fee, endingBalance);
}
}
function repay(IERC20 token, uint256 amount) public {
if (!s_currentlyFlashLoaning[token]) {
revert ThunderLoan__NotCurrentlyFlashLoaning();
}
AssetToken assetToken = s_tokenToAssetToken[IERC20(token)];
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 {
MockFeeOnTransferToken sta = new MockFeeOnTransferToken(100);
thunderLoan.setAllowedToken(IERC20(address(sta)), true);
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));
uint256 fee = thunderLoan.getCalculatedFee(IERC20(address(sta)), 1_000e18);
uint256 poolBalanceAfter = sta.balanceOf(address(assetToken));
uint256 actualFeeCollected = poolBalanceAfter - poolBalanceBefore + 1_000e18;
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)];
uint256 balanceBefore = token.balanceOf(address(assetToken));
token.safeTransferFrom(msg.sender, address(assetToken), amount);
uint256 actualReceived = token.balanceOf(address(assetToken)) - balanceBefore;
s_amountRepaid[token] += actualReceived;
}