TSwap Protocol Audit Report
June 2024 (2481 Words, 14 Minutes)
Protocol Summary
TSWAP is an constant-product AMM that allows users permissionlessly trade WETH and any other ERC20 token set during deployment. Users can trade without restrictions, just paying a tiny fee in each swapping operation. Fees are earned by liquidity providers, who can deposit and withdraw liquidity at any time.
Github repo of the audited protocol
Roles
- Liquidity Provider: An account who deposits assets into the pool to earn trading fees.
- User: An account who swaps tokens.
Executive Summary
Issues found
Severity | Number of issues found |
---|---|
High | 4 |
Medium | 2 |
Low | 2 |
Info | 4 |
Total | 12 |
Findings
High
[H-1] Incorrect fee calculation in TSwapPool::getInputAmountBasedOnOutput
causes protocol to take too many tokens from users, resulting in lost fees.
Description: The getInputAmountBasedOnOutput
function is intended to calculate the amount of tokens a user should deposit given an amount of tokens of output tokens. However, the function currently miscalculates the resulting amount. When calculating the fee, it scales the amount by 10_000 instead of 1_000.
Impact: Protocol takes more fees than expected from users.
Proof of Concept:
function testFlawedSwapExactOutput() public {
uint256 initialLiquidity = 100e18;
vm.startPrank(liquidityProvider);
weth.approve(address(pool), initialLiquidity);
poolToken.approve(address(pool), initialLiquidity);
pool.deposit({
wethToDeposit: initialLiquidity,
minimumLiquidityTokensToMint: 0,
maximumPoolTokensToDeposit: initialLiquidity,
deadline: uint64(block.timestamp)
});
vm.stopPrank();
// User has 11 pool tokens
address someUser = makeAddr("someUser");
uint256 userInitialPoolTokenBalance = 11e18;
poolToken.mint(someUser, userInitialPoolTokenBalance);
vm.startPrank(someUser);
// Users buys 1 WETH from the pool, paying with pool tokens
poolToken.approve(address(pool), type(uint256).max);
pool.swapExactOutput(
poolToken,
weth,
1 ether,
uint64(block.timestamp)
);
// Initial liquidity was 1:1, so user should have paid ~1 pool token
// However, it spent much more than that. The user started with 11 tokens, and now only has less than 1.
assertLt(poolToken.balanceOf(someUser), 1 ether);
vm.stopPrank();
// The liquidity provider can rug all funds from the pool now,
// including those deposited by user.
vm.startPrank(liquidityProvider);
pool.withdraw(
pool.balanceOf(liquidityProvider),
1, // minWethToWithdraw
1, // minPoolTokensToWithdraw
uint64(block.timestamp)
);
assertEq(weth.balanceOf(address(pool)), 0);
assertEq(poolToken.balanceOf(address(pool)), 0);
}
Recommended Mitigation:
function getInputAmountBasedOnOutput(
uint256 outputAmount,
uint256 inputReserves,
uint256 outputReserves
)
public
pure
revertIfZero(outputAmount)
revertIfZero(outputReserves)
returns (uint256 inputAmount)
{
- return ((inputReserves * outputAmount) * 10_000) / ((outputReserves - outputAmount) * 997);
+ return ((inputReserves * outputAmount) * 1_000) / ((outputReserves - outputAmount) * 997);
}
[H-2] Lack of slippage protection in TSwapPool::swapExactOutput
causes users to potentially receive way fewer tokens.
Description: The swapExactOutput
function does not include any sort of slippage protection. This function is similar to what is done in TSwapPool::swapExactInput
, where the function specifies a minOutputAmount
, the swapExactOutput
function should specify a maxInputAmount
.
Impact: If market conditions change before the transaciton processes, the user could get a much worse swap.
Proof of Concept:
- The price of 1 WETH right now is 1,000 USDC
- User inputs a
swapExactOutput
looking for 1 WETH- inputToken = USDC
- outputToken = WETH
- outputAmount = 1
- deadline = whatever
- The function does not offer a maxInput amount.
- As the transaction is pending in the mempool, the market changes! And the price moves HUGE -> 1 WETH is now 10,000 USDC. 10x more than the user expected.
- The transaction completes, but the user sent the protocol 10,000 USDC instead of the expected 1,000 USDC.
Recommended Mitigation: We should include a maxInputAmount
so the user only has to spend up to a specific amount, and can predict how much they will spend on the protocol.
function swapExactOutput(
IERC20 inputToken,
+ uint256 maxInputAmount,
.
.
.
inputAmount = getInputAmountBasedOnOutput(outputAmount, inputReserves, outputReserves);
+ if(inputAmount > maxInputAmount){
+ revert();
+ }
_swap(inputToken, inputAmount, outputToken, outputAmount);
[H-3] TSwapPool::sellPoolTokens
mismatches input and output tokens, causing users to receive the incorrect amount of tokens.
Description: The sellPoolTokens
function is intended to allow users to easily sell pool tokens and receive WETH in exchange. Users indicate how many pool tokens they’re willing to sell in the poolTokenAmount
parameter. However, the function currently miscalculates the swapped amount.
This is due to the fact that the swapExactOutput
function is called, whereas the swapExactInput
function is the one that should be called. Because users specify the exact amount of input tokens, not output.
Impact: Users will swap the wrong amount of tokens, which is a severe disruption of protcol functionality.
Proof of Concept:
2. Alice deposits 1000 WETH and 2000 Pool Tokens.
3. Bob wants to sell 500 Pool Tokens for WETH. He calls the sellPoolTokens function.
4. In the current implementation, sellPoolTokens calls swapExactOutput.
5. Due to incorrect calculations, Bob receives an unexpected amount of WETH, which can be much lower than the fair value.
Recommended Mitigation:
Consider changing the implementation to use swapExactInput
instead of swapExactOutput
. Note that this would also require changing the sellPoolTokens
function to accept a new parameter (i.e. minWethToReceive
to be passed to swapExactInput
)
function sellPoolTokens(
uint256 poolTokenAmount,
+ uint256 minWethToReceive,
) external returns (uint256 wethAmount) {
- return swapExactOutput(i_poolToken, i_wethToken, poolTokenAmount, uint64(block.timestamp));
+ return swapExactInput(i_poolToken, poolTokenAmount, i_wethToken, minWethToReceive, uint64(block.timestamp));
}
Additionally, it might be best to add a deadline to the function, as there is currently no deadline.
[H-4] In TSwapPool::_swap
the extra tokens given to users after every swapCount
breaks the protocol invariant of x * y = k
Description: The protocol follows a strict invariant of x * y = k
. Where:
x
: The balance of the pool tokeny
: The balance of WETHk
: The constant product of the two balances
This means, that whenever the balances change in the protocol, the ratio between the two amounts should remain constant, hence the k
. However, this is broken due to the extra incentive in the _swap
function. Meaning that over time the protocol funds will be drained.
The follow block of code is responsible for the issue.
swap_count++;
if (swap_count >= SWAP_COUNT_MAX) {
swap_count = 0;
outputToken.safeTransfer(msg.sender, 1_000_000_000_000_000_000);
}
Impact: A user could maliciously drain the protocol of funds by doing a lot of swaps and collecting the extra incentive given out by the protocol.
Most simply put, the protocol’s core invariant is broken.
Proof of Concept:
- A user swaps 10 times, and collects the extra incentive of
1_000_000_000_000_000_000
tokens - That user continues to swap untill all the protocol funds are drained
Proof of Code:
Place the following into TSwapPool.t.sol
.
function testInvariantBroken() 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 = 1e17;
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);
}
Recommended Mitigation: Remove the extra incentive mechanism. If you want to keep this in, we should account for the change in the x * y = k protocol invariant. Or, we should set aside tokens in the same way we do with fees.
- 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);
- }
Medium
[M-1] TSwapPool::deposit
is missing deadline check causing transactions to complete even after the deadline.
Description: The deposit
function accepts a deadline parameter, which according to the documentation is “The deadline for the transaction to be completed by”. However, this parameter is never used. As a consequence, operationrs that add liquidity to the pool might be executed at unexpected times, in market conditions where the deposit rate is unfavourable.
Impact: Transactions could be sent when market conditions are unfavourable to deposit, even when adding a deadline parameter.
Proof of Concept: The deadline
parameter is unused.
Recommended Mitigation: Consider making the following change to the function.
function deposit(
uint256 wethToDeposit,
uint256 minimumLiquidityTokensToMint,
uint256 maximumPoolTokensToDeposit,
uint64 deadline
)
external
revertIfZero(wethToDeposit)
+ revertIfDeadlinePassed(deadline)
returns (uint256 liquidityTokensToMint)
{
[M-2] Rebase, fee-on-transfer, ERC777, and centralized ERC20s can break core invariant
Description: The core invariant of the protocol is:
x * y = k
. In practice though, the protocol takes fees and actually increases k. So we need to make sure x * y = k
before fees are applied.
Impact:
Rebase: Inaccurate liquidity calculations due to dynamic token supplies.
Fee-on-transfer: Discrepancies in token balances, potentially leading to mispriced swaps.
ERC777: Unexpected behavior or missed events during token interactions.
Centralized ERC20s: Compatibility issues with tokens having different mechanics.
Recommended Mitigation:
Adapt protocol to handle dynamic token supplies from rebases. Incorporate fee calculations to ensure accurate token balances. Update contract to properly interact with ERC777 tokens. Review and adjust assumptions for compatibility with centralized ERC20 tokens.
Low
[L-1] TSwapPool::LiquidityAdded
event has parameters out of order, causing event to emit incorrect information
Description: When the LiquidityAdded
event is emitted in the TSwapPool::_addLiquidityMintAndTransfer
function, it logs values in an incorrect order. The poolTokensToDeposit
value should go in the third parameter position, whereas the wethToDeposit
value should go second.
Impact: Event emission is incorrect, leading to off-chain functions potentially malfunctioning.
Recommended Mitigation:
- emit LiquidityAdded(msg.sender, poolTokensToDeposit, wethToDeposit);
+ emit LiquidityAdded(msg.sender, wethToDeposit, poolTokensToDeposit);
[L-2] Default value returned by TSwapPool::swapExactInput
results in incorrect return value given
Description: The swapExactInput
function is expected to return the actual amount of tokens bought by the caller. However, while it declares the named return value ouput
it is never assigned a value, nor uses an explict return statement.
Impact: The return value will always be 0, giving incorrect information to the caller.
Recommended Mitigation:
{
uint256 inputReserves = inputToken.balanceOf(address(this));
uint256 outputReserves = outputToken.balanceOf(address(this));
- uint256 outputAmount = getOutputAmountBasedOnInput(inputAmount, inputReserves, outputReserves);
+ output = getOutputAmountBasedOnInput(inputAmount, inputReserves, outputReserves);
- if (outputAmount < minOutputAmount) {
- revert TSwapPool__OutputTooLow(outputAmount, minOutputAmount);}
+ if (output < minOutputAmount) {
+ revert TSwapPool__OutputTooLow(output, minOutputAmount);}
- _swap(inputToken, inputAmount, outputToken, outputAmount);
+ _swap(inputToken, inputAmount, outputToken, output);
}
Informationals
[I-1] PoolFacotry::createPool
should use .symbol()
instead of .name()
- string memory liquidityTokenSymbol = string.concat("ts", IERC20(tokenAddress).name());
+ string memory liquidityTokenSymbol = string.concat("ts", IERC20(tokenAddress).symbol());
[I-2] PoolFactory::PoolFactory_PoolDoesNotExist
is not used and should be removed
- error PoolFactory__PoolDoesNotExist(address tokenAddress);
[I-3] PoolFactory::constructor
lacking zero address checks for wethToken
constructor(address wethToken){
+ if(wethToken == address(0)){
+ revert();
+ }
i_wethToken = wethToken;
}
[I-4]: Event is missing indexed
fields
Index event fields make the field more quickly accessible to off-chain tools that parse events. However, note that each index field costs extra gas during emission, so it’s not necessarily best to index the maximum allowed per event (three fields). Each event should use three indexed fields if there are three or more fields, and gas usage is not particularly of concern for the events in question. If there are fewer than three fields, all of the fields should be indexed.
4 Found Instances:
-
Found in src/PoolFactory.sol Line: 36
event PoolCreated(address tokenAddress, address poolAddress);
-
Found in src/TSwapPool.sol Line: 52
event LiquidityAdded(
-
Found in src/TSwapPool.sol Line: 57
event LiquidityRemoved(
-
Found in src/TSwapPool.sol Line: 62
event Swap(