Thunder Loan

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

Deposit fee inflates exchange rate beyond vault backing

Description

Normal behavior

When a liquidity provider deposits tokens, the protocol may charge a deposit fee that accrues to the existing LPs. The exchange rate should only increase by the actual value added to the vault, measured in the same token units as the vault balance.

Specific issue

ThunderLoan.deposit() calls getCalculatedFee(token, amount) to compute the deposit fee. That fee is denominated in WETH (via the oracle price), while AssetToken.updateExchangeRate() applies it directly to the token-denominated exchange rate. The result is an exchange rate that grows faster than the actual vault backing, so a solo LP cannot redeem the full implied underlying amount.

// src/protocol/ThunderLoan.sol
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);
// Fee is in WETH-equivalent units, not underlying token units
@> uint256 calculatedFee = getCalculatedFee(token, amount);
@> assetToken.updateExchangeRate(calculatedFee);
token.safeTransferFrom(msg.sender, address(assetToken), amount);
}
// src/protocol/AssetToken.sol
function updateExchangeRate(uint256 fee) external onlyThunderLoan {
// ...
exchangeRate = (totalSupply() + fee) * EXCHANGE_RATE_PRECISION / totalSupply();
}

Risk

Likelihood

  • The deposit path is a core user flow and the fee is applied on every deposit.

  • The bug triggers when the oracle-derived WETH fee differs from the deposited token unit value.

Impact

  • Solo LPs or the last redeemer cannot fully redeem because the exchange rate exceeds the vault balance.

  • The protocol records a liability larger than its assets.


Proof of Concept

The PoC deploys a fresh ThunderLoan instance, creates a single LP, and deposits tokens. When the LP tries to redeem all shares, the transaction reverts because the exchange rate has been inflated beyond the vault balance.

// SPDX-License-Identifier: MIT
pragma solidity 0.8.20;
import { ThunderLoan } from "src/protocol/ThunderLoan.sol";
import { AssetToken } from "src/protocol/AssetToken.sol";
import { ERC1967Proxy } from "@openzeppelin/contracts/proxy/ERC1967Proxy.sol";
import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
contract MockPoolFactoryPoc {
function createPool(address token, uint256 price) external {}
}
contract AssetTokenShareTest {
function test_H5_PoC_depositFeeInflatesRate_unredeemable() public {
ThunderLoan freshImpl = new ThunderLoan();
ERC1967Proxy freshProxy = new ERC1967Proxy(address(freshImpl), "");
ThunderLoan fresh = ThunderLoan(address(freshProxy));
MockPoolFactoryPoc factory = new MockPoolFactoryPoc();
fresh.initialize(address(factory));
factory.createPool(address(token), 1e18);
vm.prank(fresh.owner());
fresh.setAllowedToken(token, true);
address soloLp = address(0x1);
token.mint(soloLp, 50e18);
vm.startPrank(soloLp);
token.approve(address(fresh), 50e18);
fresh.deposit(token, 50e18);
AssetToken asset = fresh.getAssetFromToken(token);
uint256 shares = asset.balanceOf(soloLp);
@> vm.expectRevert();
fresh.redeem(token, shares);
vm.stopPrank();
}
}

Recommended Mitigation

Accrue deposit fees in the same unit as the vault balance (underlying tokens), not in WETH-equivalent units. Alternatively, do not inflate the exchange rate with a fee that is not backed by transferred tokens.

// Option 1: remove deposit-side exchange rate update
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);
// Do not update exchange rate with an unbacked WETH-denominated fee
token.safeTransferFrom(msg.sender, address(assetToken), amount);
}
// Option 2: accrue fee only from actual flash-loan revenue
Updates

Lead Judging Commences

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