test/PoC_TL-11.t.sol deploys both ThunderLoan and ThunderLoanUpgraded behind an ERC1967Proxy with MockPoolFactory (price 1e18) and an LP deposit of 1000e18. Receiver R1 borrows 100e18 and, inside executeOperation, takes a 50e18 loan of the same token for receiver R2, which repays through approve + repay(). For each contract the tests show three things. A non-nested loan repaid through repay() succeeds and the pool grows by 0.3e18. In the nested flow, R1's repay() reverts with the exact ThunderLoan__NotCurrentlyFlashLoaning selector and all balances stay unchanged. Also in the nested flow, isCurrentlyFlashLoaning reads true before the inner loan and false after it, and R1 can still close its loan with a raw transfer to the AssetToken (pool grows by 0.45e18). Run it with forge test --match-path test/PoC_TL-11.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 ITL11 {
function flashloan(address receiverAddress, IERC20 token, uint256 amount, bytes calldata params) external;
function deposit(IERC20 token, uint256 amount) external;
function repay(IERC20 token, uint256 amount) external;
function getAssetFromToken(IERC20 token) external view returns (AssetToken);
function isCurrentlyFlashLoaning(IERC20 token) external view returns (bool);
}
contract InnerReceiver {
function executeOperation(
address token,
uint256 amount,
uint256 fee,
address,
bytes calldata
)
external
returns (bool)
{
IERC20(token).approve(msg.sender, amount + fee);
ITL11(msg.sender).repay(IERC20(token), amount + fee);
return true;
}
}
contract OuterReceiver {
enum Mode {
SINGLE_REPAY,
NESTED_REPAY,
NESTED_TRANSFER
}
Mode public mode;
address public inner;
uint256 public innerAmount;
bool public flagBeforeInner;
bool public flagAfterInner;
bool public flagAtOwnRepay;
function configure(Mode m, address _inner, uint256 _innerAmount) external {
mode = m;
inner = _inner;
innerAmount = _innerAmount;
}
function executeOperation(
address token,
uint256 amount,
uint256 fee,
address,
bytes calldata
)
external
returns (bool)
{
ITL11 tl = ITL11(msg.sender);
IERC20 t = IERC20(token);
if (mode != Mode.SINGLE_REPAY) {
flagBeforeInner = tl.isCurrentlyFlashLoaning(t);
tl.flashloan(inner, t, innerAmount, "");
flagAfterInner = tl.isCurrentlyFlashLoaning(t);
}
flagAtOwnRepay = tl.isCurrentlyFlashLoaning(t);
if (mode == Mode.NESTED_TRANSFER) {
t.transfer(address(tl.getAssetFromToken(t)), amount + fee);
} else {
t.approve(msg.sender, amount + fee);
tl.repay(t, amount + fee);
}
return true;
}
}
contract PoC_TL11 is Test {
MockPoolFactory factory;
ERC20Mock tokenA;
address lp = makeAddr("lp");
uint256 constant LP_DEPOSIT = 1000e18;
uint256 constant OUTER_AMOUNT = 100e18;
uint256 constant INNER_AMOUNT = 50e18;
function setUp() public {
factory = new MockPoolFactory();
tokenA = new ERC20Mock();
factory.createPool(address(tokenA));
}
function _deployV1() internal returns (ITL11) {
ThunderLoan impl = new ThunderLoan();
ERC1967Proxy proxy = new ERC1967Proxy(address(impl), "");
ThunderLoan tl = ThunderLoan(address(proxy));
tl.initialize(address(factory));
tl.setAllowedToken(IERC20(address(tokenA)), true);
_lpDeposit(ITL11(address(tl)));
return ITL11(address(tl));
}
function _deployUpgraded() internal returns (ITL11) {
ThunderLoanUpgraded impl = new ThunderLoanUpgraded();
ERC1967Proxy proxy = new ERC1967Proxy(address(impl), "");
ThunderLoanUpgraded tl = ThunderLoanUpgraded(address(proxy));
tl.initialize(address(factory));
tl.setAllowedToken(IERC20(address(tokenA)), true);
_lpDeposit(ITL11(address(tl)));
return ITL11(address(tl));
}
function _lpDeposit(ITL11 tl) internal {
tokenA.mint(lp, LP_DEPOSIT);
vm.startPrank(lp);
tokenA.approve(address(tl), LP_DEPOSIT);
tl.deposit(IERC20(address(tokenA)), LP_DEPOSIT);
vm.stopPrank();
}
function _fee(uint256 amount) internal pure returns (uint256) {
return (amount * 3e15) / 1e18;
}
function _setup(OuterReceiver.Mode m) internal returns (OuterReceiver r1, InnerReceiver r2) {
r1 = new OuterReceiver();
r2 = new InnerReceiver();
r1.configure(m, address(r2), INNER_AMOUNT);
tokenA.mint(address(r1), _fee(OUTER_AMOUNT));
tokenA.mint(address(r2), _fee(INNER_AMOUNT));
}
function _baseline(ITL11 tl, string memory tag) internal {
IERC20 t = IERC20(address(tokenA));
AssetToken at = tl.getAssetFromToken(t);
(OuterReceiver r1,) = _setup(OuterReceiver.Mode.SINGLE_REPAY);
uint256 poolBefore = tokenA.balanceOf(address(at));
tl.flashloan(address(r1), t, OUTER_AMOUNT, "");
uint256 poolAfter = tokenA.balanceOf(address(at));
assertTrue(r1.flagAtOwnRepay(), "flag set while the single loan is open");
assertEq(poolAfter, poolBefore + _fee(OUTER_AMOUNT), "single loan repaid via repay()");
assertFalse(tl.isCurrentlyFlashLoaning(t), "flag cleared after loan");
console.log(tag, "baseline single loan via repay(): pool grew by", poolAfter - poolBefore);
}
function _nestedRepayReverts(ITL11 tl, bytes4 sel, string memory tag) internal {
IERC20 t = IERC20(address(tokenA));
AssetToken at = tl.getAssetFromToken(t);
(OuterReceiver r1, InnerReceiver r2) = _setup(OuterReceiver.Mode.NESTED_REPAY);
uint256 poolBefore = tokenA.balanceOf(address(at));
uint256 r1Before = tokenA.balanceOf(address(r1));
uint256 r2Before = tokenA.balanceOf(address(r2));
vm.expectRevert(sel);
tl.flashloan(address(r1), t, OUTER_AMOUNT, "");
assertEq(tokenA.balanceOf(address(at)), poolBefore, "pool unchanged");
assertEq(tokenA.balanceOf(address(r1)), r1Before, "R1 unchanged");
assertEq(tokenA.balanceOf(address(r2)), r2Before, "R2 unchanged");
assertFalse(tl.isCurrentlyFlashLoaning(t), "flag reset by revert");
console.log(tag, "nested loan + outer repay(): reverted with ThunderLoan__NotCurrentlyFlashLoaning");
}
function _nestedTransferWorks(ITL11 tl, string memory tag) internal {
IERC20 t = IERC20(address(tokenA));
AssetToken at = tl.getAssetFromToken(t);
(OuterReceiver r1,) = _setup(OuterReceiver.Mode.NESTED_TRANSFER);
uint256 poolBefore = tokenA.balanceOf(address(at));
tl.flashloan(address(r1), t, OUTER_AMOUNT, "");
uint256 poolAfter = tokenA.balanceOf(address(at));
assertTrue(r1.flagBeforeInner(), "flag true at start of outer loan");
assertFalse(r1.flagAfterInner(), "BUG: inner loan exit cleared flag while outer loan still open");
assertFalse(r1.flagAtOwnRepay(), "BUG: flag false at the moment R1 must repay");
assertEq(poolAfter, poolBefore + _fee(OUTER_AMOUNT) + _fee(INNER_AMOUNT), "both loans paid with fees");
console.log(tag, "flag before inner loan:", r1.flagBeforeInner());
console.log(tag, "flag after inner loan (outer still open):", r1.flagAfterInner());
console.log(tag, "raw-transfer workaround: pool grew by", poolAfter - poolBefore);
}
function test_V1_Baseline_SingleLoanRepayWorks() public {
_baseline(_deployV1(), "[V1]");
}
function test_V1_NestedSameToken_OuterRepayReverts() public {
_nestedRepayReverts(_deployV1(), ThunderLoan.ThunderLoan__NotCurrentlyFlashLoaning.selector, "[V1]");
}
function test_V1_NestedSameToken_FlagClearedEarly_OnlyRawTransferWorks() public {
_nestedTransferWorks(_deployV1(), "[V1]");
}
function test_Upgraded_Baseline_SingleLoanRepayWorks() public {
_baseline(_deployUpgraded(), "[Upg]");
}
function test_Upgraded_NestedSameToken_OuterRepayReverts() public {
_nestedRepayReverts(
_deployUpgraded(), ThunderLoanUpgraded.ThunderLoan__NotCurrentlyFlashLoaning.selector, "[Upg]"
);
}
function test_Upgraded_NestedSameToken_FlagClearedEarly_OnlyRawTransferWorks() public {
_nestedTransferWorks(_deployUpgraded(), "[Upg]");
}
}
@@ -99,1 +99,1 @@
- mapping(IERC20 token => bool currentlyFlashLoaning) private s_currentlyFlashLoaning;
+ mapping(IERC20 token => uint256 flashLoanDepth) private s_currentlyFlashLoaning;
@@ -196,3 +196,3 @@
emit FlashLoan(receiverAddress, token, amount, fee, params);
- s_currentlyFlashLoaning[token] = true;
+ s_currentlyFlashLoaning[token] += 1;
assetToken.transferUnderlyingTo(receiverAddress, amount);
@@ -213,9 +213,9 @@
if (endingBalance < startingBalance + fee) {
revert ThunderLoan__NotPaidBack(startingBalance + fee, endingBalance);
}
- s_currentlyFlashLoaning[token] = false;
+ s_currentlyFlashLoaning[token] -= 1;
}
function repay(IERC20 token, uint256 amount) public {
- if (!s_currentlyFlashLoaning[token]) {
+ if (s_currentlyFlashLoaning[token] == 0) {
revert ThunderLoan__NotCurrentlyFlashLoaning();
}
@@ -268,3 +268,3 @@
function isCurrentlyFlashLoaning(IERC20 token) public view returns (bool) {
- return s_currentlyFlashLoaning[token];
+ return s_currentlyFlashLoaning[token] > 0;
}
@@ -99,1 +99,1 @@
- mapping(IERC20 token => bool currentlyFlashLoaning) private s_currentlyFlashLoaning;
+ mapping(IERC20 token => uint256 flashLoanDepth) private s_currentlyFlashLoaning;
@@ -194,3 +194,3 @@
emit FlashLoan(receiverAddress, token, amount, fee, params);
- s_currentlyFlashLoaning[token] = true;
+ s_currentlyFlashLoaning[token] += 1;
assetToken.transferUnderlyingTo(receiverAddress, amount);
@@ -211,9 +211,9 @@
if (endingBalance < startingBalance + fee) {
revert ThunderLoan__NotPaidBack(startingBalance + fee, endingBalance);
}
- s_currentlyFlashLoaning[token] = false;
+ s_currentlyFlashLoaning[token] -= 1;
}
function repay(IERC20 token, uint256 amount) public {
- if (!s_currentlyFlashLoaning[token]) {
+ if (s_currentlyFlashLoaning[token] == 0) {
revert ThunderLoan__NotCurrentlyFlashLoaning();
}
@@ -266,3 +266,3 @@
function isCurrentlyFlashLoaning(IERC20 token) public view returns (bool) {
- return s_currentlyFlashLoaning[token];
+ return s_currentlyFlashLoaning[token] > 0;
}