Thunder Loan

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

deposit() mints shares based on nominal amount but fee-on-transfer tokens deliver less, inflating exchange rate and stealing from new depositors

Root + Impact

Description

  • deposit() computes mintAmount using the caller-supplied amount before the transfer occurs, then mints AssetTokens to the depositor, and only then calls token.safeTransferFrom. For fee-on-transfer tokens the AssetToken vault receives fewer underlying tokens than amount, while the depositor receives shares priced on the full nominal amount.

  • The exchange rate is updated via assetToken.updateExchangeRate(calculatedFee) using a fee derived from the nominal amount, not the actual tokens received. This overstates the vault's underlying balance relative to outstanding shares.

  • Existing LPs can exploit this: by depositing a fee-on-transfer token, the new depositor inflates the exchange rate beyond what the vault actually holds, allowing pre-existing AssetToken holders to redeem at an artificially elevated rate — extracting value that was never deposited.

// ThunderLoan.sol lines 147-156
function deposit(IERC20 token, uint256 amount) external revertIfZero(amount) revertIfNotAllowedToken(token) {
AssetToken assetToken = s_tokenToAssetToken[token];
uint256 exchangeRate = assetToken.getExchangeRate();
uint256 mintAmount = (amount * assetToken.EXCHANGE_RATE_PRECISION()) / exchangeRate;
emit Deposit(msg.sender, token, amount);
assetToken.mint(msg.sender, mintAmount); // minted on nominal amount
uint256 calculatedFee = getCalculatedFee(token, amount);
assetToken.updateExchangeRate(calculatedFee); // rate updated on nominal amount
token.safeTransferFrom(msg.sender, address(assetToken), amount); // actual < amount
}

Risk

Likelihood: Medium — requires an allowed fee-on-transfer ERC-20. The pattern is reproducible by any depositor using such a token.

Impact: Medium — existing LPs gain at new depositors' expense. The vault's exchange rate diverges from its real backing over time, eventually causing last-redeemers to receive less than owed. No direct fund theft from the protocol but LP-vs-LP value extraction is repeatable.

Proof of Concept

LP1 deposits standard tokens at the baseline rate. LP2 then deposits the same nominal amount of a fee-on-transfer token — the vault receives fewer underlying tokens but the exchange rate is bumped using the full nominal amount. LP1 can now redeem for more than they deposited.

function testDepositFeeTokenInflatesRate() public {
// LP1 deposits 1000 standard tokens (baseline exchange rate = 1e18)
vm.prank(lp1);
thunderLoan.deposit(standardToken, 1000e18);
uint256 rateAfterFirstDeposit = assetToken.getExchangeRate();
// LP2 deposits 1000 fee-on-transfer tokens (1% fee → vault receives 990)
vm.prank(lp2);
thunderLoan.deposit(feeToken, 1000e18);
uint256 rateAfterSecondDeposit = feeToken_assetToken.getExchangeRate();
// Exchange rate was bumped using fee calculated on 1000, not 990
// LP1 can now redeem feeToken shares for more than they deposited
// LP2 holds shares backed by only 990 tokens, but shares were minted at 1000 value
uint256 lp1Underlying = (lp1AssetTokens * rateAfterSecondDeposit)
/ assetToken.EXCHANGE_RATE_PRECISION();
assertGt(lp1Underlying, 1000e18, "LP1 profited from LP2 fee-token deposit");
}

Recommended Mitigation

Measure actual tokens received by comparing balance before and after the safeTransferFrom, then use the real received amount for both mintAmount and fee calculation:

function deposit(IERC20 token, uint256 amount) external revertIfZero(amount) revertIfNotAllowedToken(token) {
AssetToken assetToken = s_tokenToAssetToken[token];
uint256 exchangeRate = assetToken.getExchangeRate();
- uint256 mintAmount = (amount * assetToken.EXCHANGE_RATE_PRECISION()) / exchangeRate;
- emit Deposit(msg.sender, token, amount);
- assetToken.mint(msg.sender, mintAmount);
- uint256 calculatedFee = getCalculatedFee(token, amount);
- assetToken.updateExchangeRate(calculatedFee);
- token.safeTransferFrom(msg.sender, address(assetToken), amount);
+ uint256 balanceBefore = token.balanceOf(address(assetToken));
+ token.safeTransferFrom(msg.sender, address(assetToken), amount);
+ uint256 actualReceived = token.balanceOf(address(assetToken)) - balanceBefore;
+ uint256 mintAmount = (actualReceived * assetToken.EXCHANGE_RATE_PRECISION()) / exchangeRate;
+ emit Deposit(msg.sender, token, actualReceived);
+ assetToken.mint(msg.sender, mintAmount);
+ uint256 calculatedFee = getCalculatedFee(token, actualReceived);
+ assetToken.updateExchangeRate(calculatedFee);
}
Updates

Lead Judging Commences

ai-first-flight-judge Lead Judge 18 days 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!