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 {
@> 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:
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