In my opinion, the high level code for a trading bot should be responsible for monitoring, data synchronization, and execution. The purpose of the helper classes is to abstract away the specific details of each composable DeFi primitive.
This week I have been working on abstraction. My goal was to move the payload generation for a particular arbitrage path out of the main bot structure and into the specific helper class that builds and evaluates it.
The purpose of generalizing a specific arbitrage path into executable payloads is to have a single generalized contract that can flexibly execute different strategies. You can review the lesson on Generalized Payload Executor contracts to get some background on this approach.
I have been developing a version-agnostic Uniswap Cycle Arbitrage class (Part I, Part II). That class is mostly complete now that I’ve added two key features:
Payload generation
External updates
External updates are fairly straightforward, so I won’t cover it here. Once you see a complete V3-compatible project (soon), you’ll understand what it does.
The payload generation is worth a bit more study, so we’ll cover it in some detail here.
Payload Generation
Uniswap V2
Uniswap V2 is well-understood and we have many examples of how to generate payloads an atomic arbitrage along a particular V2 path.
Uniswap V3
We’ve already learned how to find an arbitrage path with V3 pools, but have not yet assembled a V3 arbitrage payload and confirmed that it works as expected. We will do that here, then test out our payload generator against a local fork.
Mixed V2/V3
For style points, we will also build some flexibility into the payload generator so that it operates correctly regardless of the version. The following 2-pool arbitrage paths will be handled and supported:
V2 → V2
V2 → V3
V3 → V2
V3 → V3
Swap Structure
The difference in swapping behavior between V2 and V3 puts a wrinkle in the process, but it can be understood with some high level explanation.
A V2 pool performs a single post-swap check of its invariant (k
in the famous equation x*y=k
) and will revert if it is not satisfied. This means that anyone can transfer an input token to the pool, then call swap
. The advantage of this approach is that chaining V2 swaps together is straightforward:
Transfer the input token to the first pool
Call
swap
at the first pool with the second pool as the destinationCall
swap
at the second pool with the third pool as the destination(repeat as needed)
Call
swap
at the last pool with your adddress as the destination
A V3 pool does not operate on a constant product model, instead it calculates the input required for a particular output and requests that payment after doing the swap. We studied this in the V3 Callback lesson.
This is a big departure, and the structure of the V3 Pool contract is such that it prevents pre-swap transfers:
// do the transfers and collect payment
if (zeroForOne) {
if (amount1 < 0) TransferHelper.safeTransfer(token1, recipient, uint256(-amount1));
uint256 balance0Before = balance0();
IUniswapV3SwapCallback(msg.sender).uniswapV3SwapCallback(amount0, amount1, data);
require(balance0Before.add(uint256(amount0)) <= balance0(), 'IIA');
} else {
if (amount0 < 0) TransferHelper.safeTransfer(token0, recipient, uint256(-amount0));
uint256 balance1Before = balance1();
IUniswapV3SwapCallback(msg.sender).uniswapV3SwapCallback(amount0, amount1, data);
require(balance1Before.add(uint256(amount1)) <= balance1(), 'IIA');
}
After performing the amount calculations, the pool will record its current balance of token0
or token1
, then sends the callback to your contract before checking that you paid in full.
Since it checks the balance before the callback, you cannot pre-transfer the payment and must hold it.
So this means that a V2 → V3 swap cannot employ the familiar “swap with another pool address as destination” strategy.
To do a V2 → V3 swap, your contract must custody the funds as a middleman. This means that an extra transfer step is necessary. The V3 router uses this same approach, which you can see in the code for the V3SwapRouter Contract. When chaining exactInput swaps, it calls swap
at each pool (except for the last) with the router contract as the recipient. This way, the router holds intermediate transfers and pays for all swaps through its own callback mechanism.
To do a V3 → V2 swap, your contract can simply call for the swap
with the V2 as the destination. No need to custody here, since V2 can receive funds directly.
Payload Generator
Inside the UniswapLpCycle
arbitrage helper class, I have built a new method called generate_payloads
: