First Flight #18: T-Swap

First Flight #18
Beginner FriendlyDeFiFoundry
100 EXP
View results
Submission Details
Severity: high
Valid

The Protocol is at risk due to the violation of the core invariant of the protocol.

Description
The Protocol is at risk due to the violation of the core invariant of the protocol. The function TSwapPool::_swap transfers tokens from the pool that are not accounted for and breaks the ratio constant between the Token and WETH.

The documentation states:

Core Invariant
Our system works because the ratio of Token A & WETH will always stay the same. Well, for the most part. Since we add fees, our invariant technially increases.

x * y = k

The following snippet of code demonstrates a feature that breaks the core invariant.
The TSwapPool::_swap function contains the following code:

swap_count++;
if (swap_count >= SWAP_COUNT_MAX) {
swap_count = 0;
outputToken.safeTransfer(msg.sender,
1_000_000_000_000_000_000);
}

Impact

The protocol is at risk of failure due to the ratio constant being violated and it does not work as described by the documentation. This puts the protocols funds at risk.

Proof of Concept

Note: Ensure the foundry.toml file contains the following configuration we need to run fuzz tests in a repeatable way.

[fuzz]
seed = "0x100"
[invariant]
runs = 25
depth = 32
fail_on_revert = true

Create a folder called invariant in the test folder and add the files below:

  • invariant.t.sol

  • handler.t.sol

  • MockERC20.sol

Run the test using the command:

forge test --mt invariant_obeysMath -vvvv

Standalone test and supporting files listed above.

invariant.t.sol

// SPDX-License-Identifier: MIT
pragma solidity 0.8.20;
import {Test, console} from "forge-std/Test.sol";
import {StdInvariant} from "forge-std/StdInvariant.sol";
import { PoolFactory } from "../../src/PoolFactory.sol";
import { TSwapPool } from "../../src/TSwapPool.sol";
import { MockERC20 } from "./MockERC20.sol";
import { Handler } from "./Handler.t.sol";
contract Invariant is StdInvariant, Test {
TSwapPool pool;
PoolFactory factory;
MockERC20 poolToken;
MockERC20 weth;
address poolAddress;
address user;
uint256 constant STARTING_X = 200e18;
uint256 constant STARTING_Y = 200e18;
uint256 constant STARTING_HALF = 100e18;
uint256 constant STARTING_USER=100e18;
Handler handler;
function setUp() public {
poolToken = new MockERC20();
weth = new MockERC20();
factory = new PoolFactory(address(weth));
poolAddress = factory.createPool(address(poolToken));
pool = TSwapPool(poolAddress);
weth.approve(poolAddress, type(uint256).max);
poolToken.approve(poolAddress, type(uint256).max);
handler = new Handler(pool);
bytes4[] memory selectors = new bytes4[](2);
selectors[0] = handler.deposit.selector;
selectors[1] =
handler.swapPoolTokenForWethBasedOnOutputWeth.selector;
targetSelector(FuzzSelector({ addr: address(handler),
selectors: selectors }));
targetContract(address(handler));
}
function invariant_obeysMath() public {
assertEq(handler.actualDeltaWeth(),
handler.expectedDeltaWeth());
assertEq(handler.actualDeltaPoolToken(),
handler.expectedDeltaPoolToken());
}
}

handler.t.sol

// SPDX-License-Identifier: MIT
pragma solidity 0.8.20;
import { Test, console2, console } from "forge-std/Test.sol";
import { StdInvariant } from "forge-std/StdInvariant.sol";
import { TSwapPool } from "../../src/TSwapPool.sol";
import { MockERC20 } from "./MockERC20.sol";
// The hander is really an adaptor that provides a sensible interface
// for the fuzz tests
// Without this the contract would be fuzzed in ways that make no sense,
// that is there would be too much randomness, random tokens,
// random values etc.
// This would result in lots of bogus tests rather than fuzzing
// just the invariant.
// Our handler will need a reference to the actual contract
// we intend to fuzz.
// and everything that's needed to set it up.
// Our handler will also need some "guard rails" for the fuzz
// tests to invoke.
// The API the fuzzer will be allowed to use will wrap the two
// main calls we are testing
// and provide the ability to fuzz an amount for a single user
// making deposits and withdrawls
contract Handler is Test {
TSwapPool pool;
MockERC20 weth;
MockERC20 poolToken;
//These two represent the X and Y that changes when
// a `deposit(wethAmount)` is made
int256 public expectedDeltaWeth; //X
int256 public expectedDeltaPoolToken; //Y
int256 startingWeth; //X
int256 startingPoolToken; //Y
int256 public actualDeltaWeth;
int256 public actualDeltaPoolToken;
address liquidityProvider = makeAddr("lp");
address user = makeAddr("user");
constructor(TSwapPool _pool) {
pool = _pool;
weth = MockERC20(_pool.getWeth());
poolToken = MockERC20(_pool.getPoolToken());
}
// This function is the liquidity provider function
// (depositor) to the pool.
function deposit(uint256 wethAmount) public {
// We want to do this to avoid overflow errors in our tests
wethAmount = bound(wethAmount,
pool.getMinimumWethDepositAmount(),
type(uint64).max);
// This is the bootstap case - without this we get
// divide by zero errors.
if (pool.balanceOf(pool.getWeth()) == 0){
console.log("balance is zero weth branch ");
vm.startPrank(liquidityProvider);
weth.mint(liquidityProvider, wethAmount * 2);
poolToken.mint(liquidityProvider, wethAmount * 2);
weth.approve(address(pool), wethAmount * 2);
poolToken.approve(address(pool), wethAmount * 2);
pool.deposit(
wethAmount,
0,
uint256(0),
uint64(block.timestamp));
vm.stopPrank();
return;
}
//figure out what the change would be for poolToken
// based on the WETH
uint256 poolTokenAmount =
pool.getPoolTokensToDepositBasedOnWeth(wethAmount);
calculatePredictedDelta(int256(wethAmount),
int256(poolTokenAmount) );
vm.startPrank(liquidityProvider);
weth.mint(liquidityProvider, wethAmount);
poolToken.mint(liquidityProvider, poolTokenAmount);
weth.approve(address(pool), wethAmount);
poolToken.approve(address(pool), poolTokenAmount);
uint256 poolTokensToDeposit =
pool.getPoolTokensToDepositBasedOnWeth(wethAmount);
console.log("calling deposit and there is a pool of reserves...");
pool.deposit({
wethToDeposit: wethAmount,
minimumLiquidityTokensToMint: 0,
maximumPoolTokensToDeposit: poolTokensToDeposit,
deadline: uint64(block.timestamp)
});
vm.stopPrank();
//calculate the change
calculateActualDelta();
}
function swapPoolTokenForWethBasedOnOutputWeth(uint256 amountOfWeth)
public {
if ( (weth.balanceOf(address(pool)) / 2) <=
pool.getMinimumWethDepositAmount() ) {
return ;
}
// ensure we fall within an acceptable range
// bound (n, min, max)
amountOfWeth = bound(amountOfWeth,
pool.getMinimumWethDepositAmount(),
weth.balanceOf(address(pool)) / 2 );
//limit to half the total weth
// check if the pool can give us the weth we need
if ( weth.balanceOf(address(pool)) <= amountOfWeth ) {
return ;
}
// If these two values are the same, we will divide by 0
if (amountOfWeth == weth.balanceOf(address(pool))) {
return;
}
uint256 poolTokenReserves = poolToken.balanceOf(address(pool));
uint256 wethReserves = weth.balanceOf(address(pool));
uint256 amountOfPoolToken =
pool.getInputAmountBasedOnOutput(amountOfWeth,
poolTokenReserves,
wethReserves);
// * -1 because we are withdrawing
calculatePredictedDelta(int256(amountOfWeth) * -1,
int256(amountOfPoolToken));
if (poolToken.balanceOf(user) <= amountOfPoolToken) {
poolToken.mint(user, amountOfPoolToken * 2);
// 2x what they actually need just to be sure.
}
poolToken.approve(address(pool), type(uint256).max);
weth.approve(address(pool), amountOfWeth);
poolToken.approve(user, type(uint256).max);
console.log("amountOfWeth requested %s", amountOfWeth );
console.log("amountOfPoolToken needed %s", amountOfPoolToken );
console.log("balance of user weth %s",
weth.balanceOf(address(user)) );
console.log("balance of user poolToken %s",
poolToken.balanceOf(address(user)) );
// act like a user
vm.startPrank(user);
poolToken.approve(address(pool), type(uint256).max);
/*
function swapExactOutput(
IERC20 inputToken, --- poolToken
IERC20 outputToken, --- weth
uint256 outputAmount,
uint64 deadline
)*/
amountOfPoolToken = pool.swapExactOutput(
poolToken,
weth,
amountOfWeth,
uint64(block.timestamp) );
vm.stopPrank();
calculateActualDelta();
}
/**
HELPER FUNCTIONS
*/
function calculatePredictedDelta(int256 wethAmount,
int256 poolTokenAmount) internal {
startingWeth = int256(weth.balanceOf(address(pool)));
startingPoolToken = int256(poolToken.balanceOf(address(pool)));
//the change to each type
expectedDeltaWeth = wethAmount;
expectedDeltaPoolToken = poolTokenAmount;
}
function calculateActualDelta() internal {
// actual
uint256 endingWethBalance = weth.balanceOf(address(pool));
uint256 endingPoolTokenBalance =
poolToken.balanceOf(address(pool));
actualDeltaWeth = int256(endingWethBalance) -
int256(startingWeth);
actualDeltaPoolToken = int256(endingPoolTokenBalance) -
int256(startingPoolToken);
}
}

MockERC20.sol

// SPDX-License-Identifier: MIT
pragma solidity 0.8.20;
import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol";
contract MockERC20 is ERC20 {
constructor() ERC20("Mock", "MOCK") {}
function mint(address to, uint256 amount) external {
_mint(to, amount);
}
}

Result:

// .... previous traces ommitted for clarity
[106367] Handler::swapPoolTokenForWethBasedOnOutputWeth(6454973039698199198119942482240540643982028891330163610098731264865829167633 [6.454e75])
├─ [281] TSwapPool::getMinimumWethDepositAmount() [staticcall]
│ └─ ← [Return] 1000000000 [1e9]
├─ [2562] MockERC20::balanceOf(TSwapPool: [0x4f81992FCe2E1846dD528eC0102e6eE1f61ed3e2]) [staticcall]
│ └─ ← [Return] 857155458499809608 [8.571e17]
├─ [281] TSwapPool::getMinimumWethDepositAmount() [staticcall]
│ └─ ← [Return] 1000000000 [1e9]
├─ [562] MockERC20::balanceOf(TSwapPool: [0x4f81992FCe2E1846dD528eC0102e6eE1f61ed3e2]) [staticcall]
│ └─ ← [Return] 857155458499809608 [8.571e17]
├─ [0] console::log("Bound result", 6225657717289988 [6.225e15]) [staticcall]
│ └─ ← [Stop]
├─ [562] MockERC20::balanceOf(TSwapPool: [0x4f81992FCe2E1846dD528eC0102e6eE1f61ed3e2]) [staticcall]
│ └─ ← [Return] 857155458499809608 [8.571e17]
├─ [562] MockERC20::balanceOf(TSwapPool: [0x4f81992FCe2E1846dD528eC0102e6eE1f61ed3e2]) [staticcall]
│ └─ ← [Return] 857155458499809608 [8.571e17]
├─ [2562] MockERC20::balanceOf(TSwapPool: [0x4f81992FCe2E1846dD528eC0102e6eE1f61ed3e2]) [staticcall]
│ └─ ← [Return] 0
├─ [562] MockERC20::balanceOf(TSwapPool: [0x4f81992FCe2E1846dD528eC0102e6eE1f61ed3e2]) [staticcall]
│ └─ ← [Return] 857155458499809608 [8.571e17]
├─ [937] TSwapPool::getInputAmountBasedOnOutput(6225657717289988 [6.225e15], 0, 857155458499809608 [8.571e17])
│ └─ ← [Return] 0
├─ [562] MockERC20::balanceOf(TSwapPool: [0x4f81992FCe2E1846dD528eC0102e6eE1f61ed3e2]) [staticcall]
│ └─ ← [Return] 857155458499809608 [8.571e17]
├─ [562] MockERC20::balanceOf(TSwapPool: [0x4f81992FCe2E1846dD528eC0102e6eE1f61ed3e2]) [staticcall]
│ └─ ← [Return] 0
├─ [2562] MockERC20::balanceOf(user: [0x6CA6d1e2D5347Bfab1d91e883F1915560e09129D]) [staticcall]
│ └─ ← [Return] 0
├─ [4989] MockERC20::mint(user: [0x6CA6d1e2D5347Bfab1d91e883F1915560e09129D], 0)
│ ├─ emit Transfer(from: 0x0000000000000000000000000000000000000000, to: user: [0x6CA6d1e2D5347Bfab1d91e883F1915560e09129D], value: 0)
│ └─ ← [Stop]
├─ [4839] MockERC20::approve(TSwapPool: [0x4f81992FCe2E1846dD528eC0102e6eE1f61ed3e2], 115792089237316195423570985008687907853269984665640564039457584007913129639935 [1.157e77])
│ ├─ emit Approval(owner: Handler: [0x5991A2dF15A8F6A256D3Ec51E99254Cd3fb576A9], spender: TSwapPool: [0x4f81992FCe2E1846dD528eC0102e6eE1f61ed3e2], value: 115792089237316195423570985008687907853269984665640564039457584007913129639935 [1.157e77])
│ └─ ← [Return] true
├─ [7639] MockERC20::approve(TSwapPool: [0x4f81992FCe2E1846dD528eC0102e6eE1f61ed3e2], 6225657717289988 [6.225e15])
│ ├─ emit Approval(owner: Handler: [0x5991A2dF15A8F6A256D3Ec51E99254Cd3fb576A9], spender: TSwapPool: [0x4f81992FCe2E1846dD528eC0102e6eE1f61ed3e2], value: 6225657717289988 [6.225e15])
│ └─ ← [Return] true
├─ [4839] MockERC20::approve(user: [0x6CA6d1e2D5347Bfab1d91e883F1915560e09129D], 115792089237316195423570985008687907853269984665640564039457584007913129639935 [1.157e77])
│ ├─ emit Approval(owner: Handler: [0x5991A2dF15A8F6A256D3Ec51E99254Cd3fb576A9], spender: user: [0x6CA6d1e2D5347Bfab1d91e883F1915560e09129D], value: 115792089237316195423570985008687907853269984665640564039457584007913129639935 [1.157e77])
│ └─ ← [Return] true
├─ [0] console::log("amountOfWeth requested %s", 6225657717289988 [6.225e15]) [staticcall]
│ └─ ← [Stop]
├─ [0] console::log("amountOfPoolToken needed %s", 0) [staticcall]
│ └─ ← [Stop]
├─ [2562] MockERC20::balanceOf(user: [0x6CA6d1e2D5347Bfab1d91e883F1915560e09129D]) [staticcall]
│ └─ ← [Return] 55878861811244416264 [5.587e19]
├─ [0] console::log("balance of user weth %s", 55878861811244416264 [5.587e19]) [staticcall]
│ └─ ← [Stop]
├─ [562] MockERC20::balanceOf(user: [0x6CA6d1e2D5347Bfab1d91e883F1915560e09129D]) [staticcall]
│ └─ ← [Return] 0
├─ [0] console::log("balance of user poolToken %s", 0) [staticcall]
│ └─ ← [Stop]
├─ [0] VM::startPrank(user: [0x6CA6d1e2D5347Bfab1d91e883F1915560e09129D])
│ └─ ← [Return]
├─ [4839] MockERC20::approve(TSwapPool: [0x4f81992FCe2E1846dD528eC0102e6eE1f61ed3e2], 115792089237316195423570985008687907853269984665640564039457584007913129639935 [1.157e77])
│ ├─ emit Approval(owner: user: [0x6CA6d1e2D5347Bfab1d91e883F1915560e09129D], spender: TSwapPool: [0x4f81992FCe2E1846dD528eC0102e6eE1f61ed3e2], value: 115792089237316195423570985008687907853269984665640564039457584007913129639935 [1.157e77])
│ └─ ← [Return] true
├─ [10809] TSwapPool::swapExactOutput(MockERC20: [0x5615dEB798BB3E4dFa0139dFa1b3D433Cc23b72f], MockERC20: [0x2e234DAe75C793f67A35089C9d99245E1C58470b], 6225657717289988 [6.225e15], 1)
│ ├─ [562] MockERC20::balanceOf(TSwapPool: [0x4f81992FCe2E1846dD528eC0102e6eE1f61ed3e2]) [staticcall]
│ │ └─ ← [Return] 0
│ ├─ [562] MockERC20::balanceOf(TSwapPool: [0x4f81992FCe2E1846dD528eC0102e6eE1f61ed3e2]) [staticcall]
│ │ └─ ← [Return] 857155458499809608 [8.571e17]
│ ├─ [897] MockERC20::transfer(user: [0x6CA6d1e2D5347Bfab1d91e883F1915560e09129D], 1000000000000000000 [1e18])
│ │ └─ ← [Revert] ERC20InsufficientBalance(0x4f81992FCe2E1846dD528eC0102e6eE1f61ed3e2, 857155458499809608 [8.571e17], 1000000000000000000 [1e18])
│ └─ ← [Revert] ERC20InsufficientBalance(0x4f81992FCe2E1846dD528eC0102e6eE1f61ed3e2, 857155458499809608 [8.571e17], 1000000000000000000 [1e18])
└─ ← [Revert] ERC20InsufficientBalance(0x4f81992FCe2E1846dD528eC0102e6eE1f61ed3e2, 857155458499809608 [8.571e17], 1000000000000000000 [1e18])
Suite result: FAILED. 0 passed; 1 failed; 0 skipped; finished in 46.94s (46.93s CPU time)

Note the following in the output above ( 3 [Revert]' s from the bottom ) is the bonus feature noted in the NatSpec:

@dev Every 10 swaps, we give the caller an extra token as an extra incentive to keep trading on T-Swap.

[897] MockERC20::transfer(user: [0x6CA6d1e2D5347Bfab1d91e883F1915560e09129D], 1000000000000000000 [1e18])

Recommended mitigation

  • Remove the bonus token feature

References

https://github.com/Cyfrin/2024-06-t-swap/blob/d1783a0ae66f4f43f47cb045e51eca822cd059be/src/TSwapPool.sol#L377

Please Note: The Cyfrin Updraft security & auditing course (https://updraft.cyfrin.io/courses/security) was used as a basis for much of the code and the math in this POC.

Tools Used

  • Manual review

  • Foundry

Updates

Appeal created

inallhonesty Lead Judge about 1 year ago
Submission Judgement Published
Validated
Assigned finding tags:

In `TSwapPool::_swap` the extra tokens given to users after every swapCount breaks the protocol invariant of x * y = k

Support

FAQs

Can't find an answer? Chat with us on Discord, Twitter or Linkedin.