Thunder Loan

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

Repeated flash loans can create unbacked share liability after the exchange rate increases

Root + Impact

Description

Flash-loan borrowers pay a fee to the protocol, and that fee is intended to increase the AssetToken exchange rate so liquidity providers earn yield. When a flash loan is repaid, the AssetToken receives amount + fee, so only the fee portion should increase LP share value.

AssetToken.updateExchangeRate() calculates the new exchange rate as oldExchangeRate * (totalSupply + fee) / totalSupply. This treats the underlying-denominated fee as if it were an amount of AssetToken shares. Once the exchange rate is greater than 1e18, each later fee increases total share liability by more than the underlying fee actually received. Repeated legitimate flash loans therefore make the AssetToken under-reserved.

Source permalink: https://github.com/Cyfrin/2023-11-Thunder-Loan/blob/e8ce05f5530ca965165d41547b289604f873fdf6/src/protocol/AssetToken.sol#L80-L95

function updateExchangeRate(uint256 fee) external onlyThunderLoan {
// 1. Get the current exchange rate
// 2. How big the fee is should be divided by the total supply
// 3. So if the fee is 1e18, and the total supply is 2e18, the exchange rate be multiplied by 1.5
// if the fee is 0.5 ETH, and the total supply is 4, the exchange rate should be multiplied by 1.125
// it should always go up, never down
// newExchangeRate = oldExchangeRate * (totalSupply + fee) / totalSupply
// newExchangeRate = 1 (4 + 0.5) / 4
// newExchangeRate = 1.125
@> uint256 newExchangeRate = s_exchangeRate * (totalSupply() + fee) / totalSupply();
if (newExchangeRate <= s_exchangeRate) {
revert AssetToken__ExhangeRateCanOnlyIncrease(s_exchangeRate, newExchangeRate);
}
@> s_exchangeRate = newExchangeRate;
emit ExchangeRateUpdated(s_exchangeRate);
}

Source permalink: https://github.com/Cyfrin/2023-11-Thunder-Loan/blob/e8ce05f5530ca965165d41547b289604f873fdf6/src/upgradedProtocol/ThunderLoanUpgraded.sol#L178-L214

function flashloan(address receiverAddress, IERC20 token, uint256 amount, bytes calldata params) external {
AssetToken assetToken = s_tokenToAssetToken[token];
uint256 startingBalance = IERC20(token).balanceOf(address(assetToken));
// ...
uint256 fee = getCalculatedFee(token, amount);
@> assetToken.updateExchangeRate(fee);
emit FlashLoan(receiverAddress, token, amount, fee, params);
s_currentlyFlashLoaning[token] = true;
assetToken.transferUnderlyingTo(receiverAddress, amount);
// ...
uint256 endingBalance = token.balanceOf(address(assetToken));
if (endingBalance < startingBalance + fee) {
revert ThunderLoan__NotPaidBack(startingBalance + fee, endingBalance);
}
s_currentlyFlashLoaning[token] = false;
}

The exchange-rate update should add the underlying fee to total underlying assets, then divide by share supply. Instead, the current formula multiplies the fee by the old exchange rate:

current liability increase = fee * oldExchangeRate / 1e18
expected liability increase = fee

When oldExchangeRate > 1e18, the current liability increase is greater than the underlying fee repaid.

Risk

Likelihood:

  • This occurs after the exchange rate has increased above 1e18 and any later flash loan pays a nonzero fee.

  • This is reached through ordinary protocol usage: one or more successful flash loans increase the exchange rate, then a later successful flash loan repeats the same fee-accrual path.

Impact:

  • Repeated legitimate flash loans create unbacked share liability. In the PoC, after two fully repaid flash loans in a clean market, share liability is 1000600090000000000000 while live reserves are only 1000600000000000000000.

  • LPs can become unable to fully redeem their shares because the exchange rate promises more underlying than the AssetToken holds.

Proof of Concept

The PoC uses ThunderLoanUpgraded only to isolate this issue from the separate deposit-time exchange-rate bug in ThunderLoan.deposit(). ThunderLoanUpgraded.deposit() does not increase the exchange rate during deposit, so the market starts solvent before flash-loan activity.

Add this test:

// SPDX-License-Identifier: MIT
pragma solidity 0.8.20;
import { AssetToken } from "../src/protocol/AssetToken.sol";
import { BaseTest } from "./unit/BaseTest.t.sol";
import { ERC1967Proxy } from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol";
import { MockFlashLoanReceiver } from "./mocks/MockFlashLoanReceiver.sol";
import { ThunderLoanUpgraded } from "../src/upgradedProtocol/ThunderLoanUpgraded.sol";
contract I01LpReservesCoverShareLiabilitiesTest is BaseTest {
uint256 private constant LP_A_DEPOSIT = 1_000e18;
uint256 private constant FLASH_LOAN_AMOUNT = 100e18;
address private lpA = address(0xA11CE);
address private borrower = address(0xCAFE);
function test_I01_03_repeatedFlashLoansOnBackedMarketKeepLiveReservesAboveShareLiabilities() public {
ThunderLoanUpgraded upgradedImplementation = new ThunderLoanUpgraded();
ERC1967Proxy upgradedProxy = new ERC1967Proxy(address(upgradedImplementation), "");
ThunderLoanUpgraded upgradedThunderLoan = ThunderLoanUpgraded(address(upgradedProxy));
upgradedThunderLoan.initialize(address(mockPoolFactory));
upgradedThunderLoan.setAllowedToken(tokenA, true);
AssetToken assetToken = upgradedThunderLoan.getAssetFromToken(tokenA);
tokenA.mint(lpA, LP_A_DEPOSIT);
vm.startPrank(lpA);
tokenA.approve(address(upgradedThunderLoan), LP_A_DEPOSIT);
upgradedThunderLoan.deposit(tokenA, LP_A_DEPOSIT);
vm.stopPrank();
_assertSolvent(assetToken, "setup: upgraded deposit made reserves insolvent");
uint256 fee = upgradedThunderLoan.getCalculatedFee(tokenA, FLASH_LOAN_AMOUNT);
assertGt(fee, 0, "setup: flash loan fee is zero");
vm.prank(borrower);
MockFlashLoanReceiver receiver = new MockFlashLoanReceiver(address(upgradedThunderLoan));
tokenA.mint(address(receiver), fee);
vm.prank(borrower);
upgradedThunderLoan.flashloan(address(receiver), tokenA, FLASH_LOAN_AMOUNT, "");
_assertSolvent(assetToken, "first paid flash loan made reserves insolvent");
tokenA.mint(address(receiver), fee);
vm.prank(borrower);
upgradedThunderLoan.flashloan(address(receiver), tokenA, FLASH_LOAN_AMOUNT, "");
_assertSolvent(assetToken, "second paid flash loan made reserves insolvent");
}
function _assertSolvent(AssetToken assetToken, string memory message) private view {
assertLe(_shareLiability(assetToken), tokenA.balanceOf(address(assetToken)), message);
}
function _shareLiability(AssetToken assetToken) private view returns (uint256) {
return (assetToken.totalSupply() * assetToken.getExchangeRate()) / assetToken.EXCHANGE_RATE_PRECISION();
}
}

Run:

forge test --match-contract I01LpReservesCoverShareLiabilitiesTest --match-test test_I01_03_repeatedFlashLoansOnBackedMarketKeepLiveReservesAboveShareLiabilities -vvv

Result:

[FAIL: second paid flash loan made reserves insolvent: 1000600090000000000000 > 1000600000000000000000]

The first paid flash loan remains solvent. The second paid flash loan creates a deficit because the protocol receives a 0.3e18 fee but increases liabilities by slightly more than 0.3e18.

Recommended Mitigation

Calculate the new exchange rate by adding the underlying fee value to the current total underlying value, then dividing by total shares.

function updateExchangeRate(uint256 fee) external onlyThunderLoan {
- uint256 newExchangeRate = s_exchangeRate * (totalSupply() + fee) / totalSupply();
+ uint256 supply = totalSupply();
+ uint256 newExchangeRate = (s_exchangeRate * supply + fee * EXCHANGE_RATE_PRECISION) / supply;
if (newExchangeRate <= s_exchangeRate) {
revert AssetToken__ExhangeRateCanOnlyIncrease(s_exchangeRate, newExchangeRate);
}
s_exchangeRate = newExchangeRate;
emit ExchangeRateUpdated(s_exchangeRate);
}

Also add regression coverage for at least two consecutive paid flash loans, asserting after each loan that:

AssetToken underlying balance >= totalSupply * exchangeRate / EXCHANGE_RATE_PRECISION
Updates

Lead Judging Commences

ai-first-flight-judge Lead Judge 1 day 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!