Writing a Custom Swap Router
Part I: The Easy Part: Interacting directly with a Uniswap V2 pair.
Prelude
I spent a lot of time writing this code and it turns out that what I was trying to do with it was a little more complex than I thought. I am now exploring other use cases. Turns out this it’s very useful and actually a lot of fun to write your own router contract.
I pushed the code to Github and decided to document the task on substack. Maybe it’ll help someone with something, maybe it’ll inspire someone to reach out to me and help me write some cool things for fun and profit, or maybe it’ll help my current job search. Who knows, whatever, let’s get to it. Today, I will go over Uniswap Version 2.
V2: A Total Cakewalk
While EOA’s cannot do this alone, Uniswap V2 Liquidity Pools can don’t require a callback in order for a contract to perform a swap. So, if you want to by pass the swap router you just need to write your own contract, transfer tokens to it, do some calculations, and swap away.
I wanted my contract to be able to automatically find a liquidity pool for a given pair, if it exists, and then execute a swap directly. I first needed to do this years ago in order to sell a poorly written honeypot token that didn’t allow `transferFrom` calls, but selling was possible with `transfer` (rare these days).
Later, it occurred to me that this method allows me to get the best possible price, because I have absolute control over the slippage tolerance. I also wanted my contract to automatically wrap Ether if the call has a `msg.value > 0` in order to simplify the process. This is the core logic that I ended up with.
The entire V2 library is hosted at: https://github.com/darkerego/DirectSwap/blob/auto/contracts/libs/SwapV2.sol
I wrote an external, virtual wrapper function:
/*
* @notice Swap tokens externally using Uniswap V2.
* @dev WARN: UNAUTHENTICATED! Override this function auth modifier
* @dev: if (`msg.value>0`) , value must match amountIn
* @notice: acceptable slippage in basis points
*/
function swapV2(
address tokenIn: // token to sell
address tokenOut, // token to buy
uint256 amountIn, // amount of `tokenIn` or `msg.Value` *
uint32 slippageBps // 0=exact, 1=0.01%, 100=1%, etc
) external payable virtual returns(address, uint256) {
return _swapV2(tokenIn, tokenOut, amountIn, slippageBps, true, true);
}
That function calls the internal function that handles all of the logic. First, we need to make sure that there is a pool for the pair requested in existence:
// V2 swap (direction-proof) with slippageBps
function _swapV2(
address tokenIn,
address tokenOut,
uint256 amountIn,
uint32 slippageBps, // basis points: 0 = exact
bool pullIn,
bool pushOut
) internal returns (address pair, uint256 amountOut) {
pair = IUniswapV2Factory(factory).getPair(tokenIn, tokenOut);
if (pair == address(0)) revert PairNotFound();
Because uniswap v2’s `swap` function does not return the output amount like v3, first I store in memory the current token balance of the contract so I can calculate it again later and log the amount tokens received via an event after the swap. Then, if `pullIn` is true, the contract either wraps ether sent with the call, or it checks the allowance of the contract for the user’s account and if sufficient proceeds to call `safeTransferFrom`, a gas efficient and reliable assembly replacement for `IERC20(token).transferFrom` from my custom, gas efficient, fail-safe logic contained in the abstract TransferHelper contract . Otherwise, it’s assumed the contract already has the tokens and simply sends them to the pair.
uint256 tokenOutBalanceBefore = tokenBalance(tokenOut, address(this));
// deposit WETH if needed (if caller sent ETH and tokenIn == wrappedEther)
if (tokenIn == wrappedEther && msg.value > 0) {
if (msg.value != amountIn) revert MismatchedEthAmount(msg.value, amountIn);
IWETH(wrappedEther).deposit{value: msg.value}();
safeTransfer(wrappedEther, pair, amountIn);
} else {
if (pullIn) {
if (tokenAllowance(tokenIn, msg.sender, address(this)) < amountIn) {
revert InsufficientAllowance(tokenIn, msg.sender, amountIn);
}
safeTransferFrom(tokenIn, msg.sender, pair, amountIn);
} else {
safeTransfer(tokenIn, pair, amountIn);
}
}
Next, I perform a very basic sanity check to ensure that the pair has liquidity and determine swap direction. I bet there is a more efficient way to determine direction, and a lot of other things in this contract and I will be updating this for optimization in the near future, but this works perfectly fine.
(uint112 reserve0, uint112 reserve1,) = IUniswapV2Pair(pair).getReserves();
address token0 = IUniswapV2Pair(pair).token0();
address token1 = IUniswapV2Pair(pair).token1();
// basic sanity: reserves must meet threshold
if (uint256(reserve0) + uint256(reserve1) < MIN_V2_RESERVE) {
revert PairNotFound();
}
bool zeroForOne;
uint256 reserveIn;
uint256 reserveOut;
if (tokenIn == token0) {
zeroForOne = true;
reserveIn = reserve0;
reserveOut = reserve1;
} else if (tokenIn == token1) {
zeroForOne = false;
reserveIn = reserve1;
reserveOut = reserve0;
} else {
revert InvalidToken(tokenIn);
}
Finally, we get a quote (the contract also of course has external quote and swap test functions designed to allow simulating the results before execution), perform slippage calculations if required, and call `pair.swap`. The pair contract will optimistically send us the tokens we requested and then ensure that we have sent enough input token to purchase them and if so it’ll send us the requested token. The last step is to either unwrap ether and send it back to the caller, or transfer the received tokens to the caller if requested by the `pullOut` boolean parameter.
uint256 amountOutExpected = getAmountOut(amountIn, reserveIn, reserveOut);
uint256 minAmountOut = amountOutExpected;
if (slippageBps > 0) {
minAmountOut = (amountOutExpected * (10000 - slippageBps)) / 10000;
}
uint amount0Out = zeroForOne ? 0 : amountOutExpected;
uint amount1Out = zeroForOne ? amountOutExpected : 0;
IUniswapV2Pair(pair).swap(amount0Out, amount1Out, address(this), new bytes(0));
amountOut = tokenBalance(tokenOut, address(this)) - tokenOutBalanceBefore;
if (amountOut < minAmountOut) revert SlippageExceeded(amountOut, minAmountOut);
if (pushOut) {
if (tokenOut == wrappedEther) {
IWETH(wrappedEther).withdraw(amountOut);
executeCall(msg.sender, amountOut, new bytes(0));
} else {
safeTransfer(tokenOut, msg.sender, amountOut);
}
}
}
Although this function could be exposed externally I designed it to be called by another external wrapper function, and there are several ways to trigger this function. The push and pull parameters allow “entering” and “exiting” positions, if you will. They also allow multiple swaps in the same transaction with my custom `multiHopSwap` external function which uses a struct (tuple) with a parameter that determines whether or not to call swapV2 or swapV3.
That’s all for today. Next time, I will go through the internal _swapV3 logic, and the v4 logic after I finish writing it. Uniswap seems to get a bit more complex with each new version, but all we’re really doing is writing our own swap router, the pools can indeed be called on v4 by contracts other than the router, so, it is absolutely possible to do.
You can find all of the code for the entire project on my Github account at this URL:
https://github.com/darkerego/DirectSwap/tree/auto/contracts
Thanks for reading Spoonful of Silicon.