Thunder Loan

AI First Flight #7
Beginner FriendlyFoundryDeFiOracle
EXP
View results
Submission Details
Severity: high
Valid

Incorrect Fee Calculation in `deposit()` Function Breaks Redemption and Violates Protocol Invariants

Root + Impact

According to the protocol specification, AssetTokens should "gain interest over time depending on how often people take out flash loans." Flash loan borrowers pay fees, and these fees should increase the exchange rate, benefiting liquidity providers.

The deposit() function incorrectly calculates a fee and updates the exchange rate as if the deposit were a flash loan. This breaks the core protocol invariant that only flash loan fees should increase the exchange rate, making immediate redemptions impossible and causing protocol insolvency.

Description

The protocol's intended flow is:

  1. Liquidity providers deposit assets and receive AssetTokens at the current exchange rate

  2. Users take flash loans and pay fees

  3. These fees increase the exchange rate, making AssetTokens more valuable

  4. Liquidity providers redeem their AssetTokens for more underlying tokens than they deposited

However, the current deposit() function implementation incorrectly treats deposits as if they generate fees:

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);
//@audit-issue: Deposits should NOT calculate fees or update exchange rate!
@> uint256 calculatedFee = getCalculatedFee(token, amount);
@>assetToken.updateExchangeRate(calculatedFee);
@>token.safeTransferFrom(msg.sender, address(assetToken), amount);
}

The getCalculatedFee() function calculates a flash loan fee based on the deposited amount:

function getCalculatedFee(
IERC20 token,
uint256 amount
) public view returns (uint256 fee) {
uint256 valueOfBorrowedToken = (amount * getPriceInWeth(address(token))) / s_feePrecision;
fee = (valueOfBorrowedToken * s_flashLoanFee) / s_feePrecision;
}

When updateExchangeRate(fee) is called in AssetToken.sol, it increases the exchange rate:

The Problem:

  • User deposits 10 tokens

  • Mints 10 AssetTokens (at 1:1 exchange rate initially)

  • Updates exchange rate with a calculated "fee" (increases to ~1.003:1)

  • Only 10 underlying tokens are in the contract

  • When user tries to redeem 10 AssetTokens, the calculation is: 10 * 1.003 / 1 = 10.03 tokens needed

  • Contract only has 10 tokens → Redemption fails!

Risk

Likelihood: High

  • Every single deposit triggers this vulnerability

  • No special conditions required

  • Affects all liquidity providers immediately upon deposit

  • The first liquidity provider to deposit will be unable to redeem their full amount

Impact: High

  • Protocol insolvency: Liquidity providers cannot withdraw their deposits

  • Violation of core protocol invariant: Interest should only come from flash loans, not deposits

  • Loss of funds: Early depositors effectively subsidize later depositors

  • Broken functionality: Basic deposit/redeem flow is non-functional

  • Cascading failures: As more deposits occur, the exchange rate keeps increasing, making the insolvency worse

Proof of Concept

The test testDepositAndImmidiatelyReedemSameAmount() demonstrates this vulnerability:

function testDepositAndImmidiatelyReedemSameAmount()
public
setAllowedToken
{
tokenA.mint(liquidityProvider, AMOUNT);
AssetToken assetToken = thunderLoan.getAssetFromToken(tokenA);
vm.startPrank(liquidityProvider);
tokenA.approve(address(thunderLoan), AMOUNT);
thunderLoan.deposit(tokenA, AMOUNT); // Deposit 10e18 tokens
vm.stopPrank();
assertEq(tokenA.balanceOf(address(assetToken)), AMOUNT); // Contract has 10e18
uint256 liquidityProviderAssetTokenBalance = assetToken.balanceOf(
liquidityProvider
); // User has 10e18 AssetTokens
vm.prank(liquidityProvider);
thunderLoan.redeem(tokenA, liquidityProviderAssetTokenBalance); // Try to redeem
// ❌ FAILS: Tries to transfer 10.03e18 but contract only has 10e18
}

Example Calculation:

With flash loan fee of 0.3% (3e15) and fee precision of 1e18:

  1. Deposit: 10e18 tokens

  2. Calculated Fee: (10e18 * 1e18) / 1e18 * 3e15 / 1e18 = 3e16 (approximately)

  3. Exchange Rate Update: 1e18 * (10e18 + 3e16) / 10e18 = 1.003e18

  4. Redemption Attempt: 10e18 AssetTokens * 1.003e18 / 1e18 = 10.03e18 tokens

  5. Available Balance: 10e18 tokens

  6. Result:Insufficient balance → Transaction reverts

Recommended Mitigation

Remove the fee calculation and exchange rate update from the deposit() function. Exchange rate updates should only occur during flash loans when actual fees are collected:

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);
}

The exchange rate should only be updated in the flashloan() function where actual fees are collected:

Updates

Lead Judging Commences

ai-first-flight-judge Lead Judge about 1 hour ago
Submission Judgement Published
Validated
Assigned finding tags:

[H-02] Updating exchange rate on token deposit will inflate asset token's exchange rate faster than expected

# Summary Exchange rate for asset token is updated on deposit. This means users can deposit (which will increase exchange rate), and then immediately withdraw more underlying tokens than they deposited. # Details Per documentation: > Liquidity providers can deposit assets into ThunderLoan and be given AssetTokens in return. **These AssetTokens gain interest over time depending on how often people take out flash loans!** Asset tokens gain interest when people take out flash loans with the underlying tokens. In current version of ThunderLoan, exchange rate is also updated when user deposits underlying tokens. This does not match with documentation and will end up causing exchange rate to increase on deposit. This will allow anyone who deposits to immediately withdraw and get more tokens back than they deposited. Underlying of any asset token can be completely drained in this manner. # Filename `src/protocol/ThunderLoan.sol` # Permalinks https://github.com/Cyfrin/2023-11-Thunder-Loan/blob/8539c83865eb0d6149e4d70f37a35d9e72ac7404/src/protocol/ThunderLoan.sol#L153-L154 # Impact Users can deposit and immediately withdraw more funds. Since exchange rate is increased on deposit, they will withdraw more funds then they deposited without any flash loans being taken at all. # Recommendations It is recommended to not update exchange rate on deposits and updated it only when flash loans are taken, as per documentation. ```diff 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); } ``` # POC ```solidity function testExchangeRateUpdatedOnDeposit() public setAllowedToken { tokenA.mint(liquidityProvider, AMOUNT); tokenA.mint(user, AMOUNT); // deposit some tokenA into ThunderLoan vm.startPrank(liquidityProvider); tokenA.approve(address(thunderLoan), AMOUNT); thunderLoan.deposit(tokenA, AMOUNT); vm.stopPrank(); // another user also makes a deposit vm.startPrank(user); tokenA.approve(address(thunderLoan), AMOUNT); thunderLoan.deposit(tokenA, AMOUNT); vm.stopPrank(); AssetToken assetToken = thunderLoan.getAssetFromToken(tokenA); // after a deposit, asset token's exchange rate has aleady increased // this is only supposed to happen when users take flash loans with underlying assertGt(assetToken.getExchangeRate(), 1 * assetToken.EXCHANGE_RATE_PRECISION()); // now liquidityProvider withdraws and gets more back because exchange // rate is increased but no flash loans were taken out yet // repeatedly doing this could drain all underlying for any asset token vm.startPrank(liquidityProvider); thunderLoan.redeem(tokenA, assetToken.balanceOf(liquidityProvider)); vm.stopPrank(); assertGt(tokenA.balanceOf(liquidityProvider), AMOUNT); } ```

Support

FAQs

Can't find an answer? Chat with us on Discord, Twitter or Linkedin.

Give us feedback!