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
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
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";
contract Handler is Test {
TSwapPool pool;
MockERC20 weth;
MockERC20 poolToken;
int256 public expectedDeltaWeth;
int256 public expectedDeltaPoolToken;
int256 startingWeth;
int256 startingPoolToken;
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());
}
function deposit(uint256 wethAmount) public {
wethAmount = bound(wethAmount,
pool.getMinimumWethDepositAmount(),
type(uint64).max);
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;
}
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();
calculateActualDelta();
}
function swapPoolTokenForWethBasedOnOutputWeth(uint256 amountOfWeth)
public {
if ( (weth.balanceOf(address(pool)) / 2) <=
pool.getMinimumWethDepositAmount() ) {
return ;
}
amountOfWeth = bound(amountOfWeth,
pool.getMinimumWethDepositAmount(),
weth.balanceOf(address(pool)) / 2 );
if ( weth.balanceOf(address(pool)) <= amountOfWeth ) {
return ;
}
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);
calculatePredictedDelta(int256(amountOfWeth) * -1,
int256(amountOfPoolToken));
if (poolToken.balanceOf(user) <= amountOfPoolToken) {
poolToken.mint(user, amountOfPoolToken * 2);
}
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)) );
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)));
expectedDeltaWeth = wethAmount;
expectedDeltaPoolToken = poolTokenAmount;
}
function calculateActualDelta() internal {
uint256 endingWethBalance = weth.balanceOf(address(pool));
uint256 endingPoolTokenBalance =
poolToken.balanceOf(address(pool));
actualDeltaWeth = int256(endingWethBalance) -
int256(startingWeth);
actualDeltaPoolToken = int256(endingPoolTokenBalance) -
int256(startingPoolToken);
}
}
MockERC20.sol
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:
[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
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