With the UniswapTransaction
helper built, and most of the bugs fixed on github, we can now turn our attention to actually using it for backruns.
First I’ll show a simple example of how to run an off-chain simulation against a sample transaction, then I’ll demonstrate how to evaluate a specific pool for potential backrun arbitrage.
Historical Demo
I’m going to use a particular transaction that gave me a lot of heartburn. It was encoded as a nested multicall to the Uniswap V3 Router. That is, the function call at the router was multicall
, and the payload inside it was another multicall
. Yikes!
Most payloads inside a multicall
are direct references to other functions (exactInput
, exactOutput
, swapExactTokensForTokens
, etc) but occasionally one of these rolled through and crashed my watcher. I suppose it’s possible that someday we will see a multicall inside a multicall inside another multicall (*Inception sound*) but for now I’m stopping at two.
The transaction in particular is on Ethereum mainnet, hash 0x2f4c0d66ecfd89d074358cb0f5ec5306317b8ff3b533a5e756ff6813b29fb972 (Etherscan link).
Here is the transaction result when fetched on a live node:
(.venv) [devil@dev bots]$ brownie console --network mainnet-local
Brownie v1.19.3 - Python development framework for Ethereum
BotsProject is the active project.
Brownie environment is ready.
>>> tx = web3.eth.get_transaction(
'0x2f4c0d66ecfd89d074358cb0f5ec5306317b8ff3b533a5e756ff6813b29fb972'
)
>>> tx
AttributeDict({'blockHash': HexBytes('0x14a9d1bd092f1f86d043ba5117f9b38c732641858ede022a70c402759daa0462'), 'blockNumber': 16703821, 'from': '0x6A480983E00b7D217b50738568298e011f3D91C9', 'gas': 153471, 'gasPrice': 20799579488, 'maxFeePerGas': 41317855470, 'maxPriorityFeePerGas': 1000000000, 'hash': HexBytes('0x2f4c0d66ecfd89d074358cb0f5ec5306317b8ff3b533a5e756ff6813b29fb972'), 'input': '0xac9650d800000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000001a45ae401dc0000000000000000000000000000000000000000000000000000000063f9bf8800000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000000e404e45aaf000000000000000000000000c02aaa39b223fe8d0a0e5c4f27ead9083c756cc2000000000000000000000000f1b99e3e573a1a9c5e6b2ce818b617f0e664e86b0000000000000000000000000000000000000000000000000000000000000bb80000000000000000000000006a480983e00b7d217b50738568298e011f3d91c9000000000000000000000000000000000000000000000000029be90101c600000000000000000000000000000000000000000000000000002cc34706c6a4985e00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000', 'nonce': 8, 'to': '0x68b3465833fb72A70ecDF485E0e4C7bD8665Fc45', 'transactionIndex': 61, 'value': 188000000000000000, 'type': '0x2', 'accessList': [], 'chainId': '0x1', 'v': 0, 'r': HexBytes('0xea0da9da7e767eb1482a5e148a1a23fef3656d31d5de7d7fc2ed5f28900b6b24'), 's': HexBytes('0x16e48a4493bc504fac5ce96bcaa8ec47ce5e0afbe83afab580f27d723150bf1c')})
Now we need to get the decoded function inputs from web3, using the router address and ABI:
>>> router = Contract.from_explorer(
'0x68b3465833fb72A70ecDF485E0e4C7bD8665Fc45'
)
>>> func_obj, func_params = web3.eth.contract(
address=router.address,
abi=router.abi)
.decode_function_input(tx['input'])
>>> func_obj.name
'multicall'
>>> func_params
{
'data': [b"Z\xe4\x01\xdc\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00c\xf9\xbf\x88\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00@\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00 \x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xe4\x04\xe4Z\xaf\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xc0*\xaa9\xb2#\xfe\x8d\n\x0e\\O'\xea\xd9\x08<ul\xc2\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xf1\xb9\x9e>W:\x1a\x9c^k,\xe8\x18\xb6\x17\xf0\xe6d\xe8k\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x0b\xb8\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00jH\t\x83\xe0\x0b}!{Ps\x85h)\x8e\x01\x1f=\x91\xc9\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x02\x9b\xe9\x01\x01\xc6\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00,\xc3G\x06\xc6\xa4\x98^\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00"]
}
Now let’s disconnect and reconnect on a local fork at block height 16703820, one before this TX was confirmed:
(.venv) [devil@dev bots]$ BLOCK=16703820 brownie console --network mainnet-fork-atblock
Brownie v1.19.3 - Python development framework for Ethereum
BotsProject is the active project.
Launching 'ganache-cli --chain.vmErrorsOnRPCResponse true --wallet.totalAccounts 10 --chain.chainId 1 --fork.url https://rpc.ankr.com/eth@16703820 --miner.blockGasLimit 12000000 --wallet.mnemonic brownie --server.port 6969 --hardfork istanbul'...
Brownie environment is ready.
Now import the degenbot module and build a UniswapTransaction
object from the values retrieved earlier:
>>> tx_helper = bot.transaction.UniswapTransaction(
'0x2f4c0d66ecfd89d074358cb0f5ec5306317b8ff3b533a5e756ff6813b29fb972',
8, 188000000000000000, 'multicall',
{
'data': [b"Z\xe4\x01\xdc\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00c\xf9\xbf\x88\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00@\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00
\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00 \x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xe4\x04\xe4Z\xaf\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xc0*\xaa9\xb2#\xfe\x8d\n\x0e\\O'\xea\xd9\x08<ul\xc2\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xf1\xb9\x9e>W:\x1a\x9c^k,\xe8\x18\xb6\x17\xf0\xe6d\xe8k\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x0b\xb8\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00jH\t\x83\xe0\x0b}!{Ps\x85h)\x8e\x01\x1f=\x91\xc9\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x02\x9b\xe9\x01\x01\xc6\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00,\xc3G\x06\xc6\xa4\x98^\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00"]
},
'0x68b3465833fb72A70ecDF485E0e4C7bD8665Fc45'
)
Now that the TX helper is built, let’s run the simulate
method and see what pops out:
>>> tx_helper.simulate()
multicall: 0x2f4c0d66ecfd89d074358cb0f5ec5306317b8ff3b533a5e756ff6813b29fb972
Unwrapping nested multicall
exactInputSingle: 0x2f4c0d66ecfd89d074358cb0f5ec5306317b8ff3b533a5e756ff6813b29fb972
• WETH (Wrapped Ether)
• oSQTH (Opyn Squeeth)
Predicting output of swap through pool: WETH-oSQTH (V3, 0.30%)
[(<degenbot.uniswap.v3.v3_liquidity_pool.V3LiquidityPool object at 0x7fb5e4cf7970>, {'amount0_delta': 188000000000000000, 'amount1_delta': -3233563602186847301, 'liquidity': 20820537642257871504826, 'sqrt_price_x96': 329068009559077276843808763983, 'tick': 28480})]
The simulate
method returns a list of tuples of the format (pool_helper
, state_dict
). The representation of the pool helper (<degenbot.uniswap.v3.v3_liquidity_pool.V3LiquidityPool object at 0x7fb5e4cf7970
) isn’t particular user-friendly, but rest assured it is seful to the script that will deal with it later. It is the pool helper associated with the WETH-oSQTH V3 pool (0.3% fee).
The state dictionary has some useful values:
'amount0_delta': 188000000000000000
'amount1_delta': -3233563602186847301
'liquidity': 20820537642257871504826
'sqrt_price_x96': 329068009559077276843808763983
'tick': 28480
These values will be very familiar if you looked closely at the Etherscan link. Here’s a clip:
And in the transaction logs, you can see the following events emitted by the swap:
Transfer
value: 3233563602186847301
Transfer
wad: 188000000000000000
Swap
amount0: 188000000000000000
amount1: -3233563602186847301
sqrtPriceX96: 329068009559077276843808763983
liquidity: 20820537642257871504826
tick: 28480
Verify that the results from the state dictionary above match the final state recorded on the chain.
This is neat on its own, but the real goal is to pass this future state dictionary into an arbitrage helper, which will find an arbitrage path using the future state instead of the current state.
Backrun Example
To be clear, I have no idea what this particular token (oSQTH) does, whether it has value, utility, transfer fees, taxes, is a honeypot, etc. I am but a humble cartoon bot builder, so let’s just roll with it and see what happens.