If you haven’t already, take an hour and watch this excellent video from Robert Miller (of Flashbots fame):
It’s easy to miss since the whole discussion is dense and very technical, but the inspiration for this post starts around the 49 minute mark.
If it’s above your head, the simple-arbitrage repo includes a sophisticated technique similar to multicall, which we used to retrieve LP data from multiple sources. This one is used to execute multiple state-changing transactions at once, instead of executing multiple read-only calls.
Now instead of coding a bespoke smart contract each time you identify a new arbitrage path, a multicall executor contract can be built that accepts and executes an arbitrary number of targets and payloads. This further shifts the burden of verification off-chain, since now the smart contract becomes a very simple “load, fire, repeat” mechanism.
It’s not obvious at first glance, but here is the appeal — if I deploy a generic smart contract, I can use it “forever” without making modifications. This is because at the core, EVM does not have any native understanding of DeFi protocols, Uniswap V2 algorithms, arbitrage, etc. It simply receives bytecode calldata, translates that to a series of instructions encoded at a certain address, then executes those instructions. No matter what new DEX or swap contract appears in the future, I can simply translate their functionality into a generic payload, provide the address, and EVM will perform that exact functionality without my smart contract understanding anything about it.
I have been working to understand and implement the behavior of the BundleExecutor.sol contract in Vyper. I’ve been in contact with the Vyper developers on Discord and Github, one critical feature has been added to 0.3.4 (not yet released), and I am pleased to report that it works!
There are some kinks to work out still, but this is an encouraging first step.
Vyper Contract
This is going to be an extremely simple example. This contract consists of a single function named execute()
that takes two arguments: target
(an address) and payload
(a byte array). It performs no error checking other than confirming that msg.sender
matches the owner that deployed it. It will receive and relay ETH (or AVAX, FTM, etc), but otherwise is as dumb as it gets. It will execute the transaction using a special Vyper function called raw_call()
, which will send a byte array as calldata to a specified address. There are other options that we will get into later, you can read more in the raw_call()
documentation HERE.
Create a new directory (I’m using ~/vyper
) and initialize a new Brownie project inside:
devil@hades:~$ mkdir vyper
devil@hades:~$ cd vyper
devil@hades:~/vyper$ brownie init -f
Brownie v1.19.0 - Python development framework for Ethereum
SUCCESS: A new Brownie project has been initialized at /home/devil/vyper
Now create a new contract in ~/vyper/contracts
called executor.vy
:
# @version ^0.3
OWNER: immutable(address)
@external
@nonpayable
def __init__():
OWNER = msg.sender
@external
@payable
def execute(
target: address,
payload: Bytes[4096],
):
assert msg.sender == OWNER, "!OWNER"
response: Bytes[32] = raw_call(
target,
payload,
max_outsize=32,
value=msg.value,
)
Now launch Brownie console on a local Avalanche fork:
devil@hades:~/vyper$ brownie console --network avax-main-fork
Brownie v1.19.0 - Python development framework for Ethereum
VyperProject is the active project.
Launching 'ganache-cli --chain.vmErrorsOnRPCResponse true --wallet.totalAccounts 10 --hardfork istanbul --fork.url https://api.avax.network/ext/bc/C/rpc --miner.blockGasLimit 20000000 --wallet.mnemonic brownie --server.port 8545 --chain.chainId 43114'...
Brownie environment is ready.
Now we will deploy the executor contract:
>>> executor.deploy({'from':accounts[0]})
Transaction sent: 0x9882ea287eaf5f716c6a6a57ca58363dba903719d18d89d9a030950ba5a2928c
Gas price: 0.0 gwei Gas limit: 20000000 Nonce: 0
executor.constructor confirmed Block: 16266932 Gas used: 121892 (0.61%)
executor deployed at: 0x3194cBDC3dbcd3E11a07892e7bA5c3394048Cc87
<executor Contract '0x3194cBDC3dbcd3E11a07892e7bA5c3394048Cc87'>
Now let’s take a step back and review some information that we already have. Jumping back to the Snowsight Transaction Propagator lesson, recall that we learned how to use web3py to construct the calldata for the deposit()
function in the WAVAX contract.
For reference:
>>> tx = wavax_web3.functions.deposit().buildTransaction({'from':degenbot.address, 'chainId':chain.id, 'gas':60000, 'maxFeePerGas':chain.base_fee, 'maxPriorityFeePerGas':0, 'nonce':degenbot.nonce, 'value':int(0.1*10**18)})
>>> tx
{
'chainId': 43114,
'data': "0xd0e30db0",
'gas': 60000,
'maxFeePerGas': 85063358173,
'maxPriorityFeePerGas': 0,
'nonce': 512,
'to': "0xB31f66AA3C1e785363F0875A1B74E27b85FD66c7",
'type': "0x2",
'value': 150000000000000000
}
The critical piece of this puzzle is the key-value pair 'data': "0xd0e30db0"
Here, the hexadecimal string "0xd0e30db0"
is known as the function selector. It represents the first four bytes of the function name and arguments, encoded using the keccak hash function.
To prove it, let’s make one more detour and calculate the function selector for deposit()
by hand:
>>> web3.keccak(text='deposit()').hex()
'0xd0e30db03f2e24c6531d8ae2f6c09d8e7a6ad7f7e87a81cb75dfda61c9d83286'
The first two characters '0x'
signify that this is a string representation of a hex number, and are not actually part of the bytes. Each hex byte is two characters wide, so we do a 10-character slice to capture the 0x and the first 4 bytes of the hash :
>>> web3.keccak(text='deposit()').hex()[0:10]
'0xd0e30db0'
This also matches the values from the ABI:
>>> wavax = Contract('0xB31f66AA3C1e785363F0875A1B74E27b85FD66c7')
>>> wavax.deposit.signature
'0xd0e30db0'
OK, I’ve beaten this horse to death. If I want to call the deposit()
function, I must build my calldata with '0xd0e30db0'
at the beginning.
Since deposit()
accepts no arguments, and only expects some WAVAX to be sent along with it, I’m done. My calldata is '0xd0e30db0'
and the target address is '0xB31f66AA3C1e785363F0875A1B74E27b85FD66c7'
.
Now back to the contract, which we will use to send the calldata payload to the WAVAX contract address, and we will send some AVAX along with it so deposit()
is satisfied:
>>> tx = executor[0].execute('0xB31f66AA3C1e785363F0875A1B74E27b85FD66c7', '0xd0e30db0',{'from':accounts
[0],'value':10*10**18})
Transaction sent: 0x303fd2321927d92f71edbccb62a1872ad7697139455079a8a91edcabbfada73b
Gas price: 0.0 gwei Gas limit: 20000000 Nonce: 1
executor.execute confirmed Block: 16266933 Gas used: 52248 (0.26%)
Sort of anti-climactic. What did it do?
>>> tx.info()
Transaction was Mined
---------------------
Tx Hash: 0x303fd2321927d92f71edbccb62a1872ad7697139455079a8a91edcabbfada73b
From: 0x66aB6D9362d4F35596279692F0251Db635165871
To: 0x3194cBDC3dbcd3E11a07892e7bA5c3394048Cc87
Value: 10000000000000000000
Function: executor.execute
Block: 16266933
Gas Used: 52248 / 20000000 (0.3%)
Events In This Transaction
--------------------------
└── Wrapped AVAX (0xB31f66AA3C1e785363F0875A1B74E27b85FD66c7)
└── Deposit
├── dst: 0x3194cBDC3dbcd3E11a07892e7bA5c3394048Cc87
└── wad: 10000000000000000000
Ah, now we’re getting somewhere! Our generic executor has successfully received and forwarded AVAX to the WAVAX contract, then called the deposit()
function, which wraps that AVAX and transfers it back to the sender.
Let’s confirm that the WAVAX is now stored inside our executor contract, and that our user has no WAVAX balance:
>>> wavax.balanceOf(executor[0])
10000000000000000000
>>> wavax.balanceOf(accounts[0])
0
And now for a final test, let’s figure out how to transfer this WAVAX from the contract back to us. This will be done using the transfer()
function within the WAVAX contract (a standard ERC-20 method). First we need to figure out the calldata for the transfer()
function:
>>> import web3
>>> w3 = web3.Web3()
>>> wavax_web3 = w3.eth.contract(address=wavax.address, abi=wavax.abi)
>>> wavax_web3.functions.transfer(accounts[0].address,10*10**18).buildTransaction({'gasPrice':100*10**9,
'gas':1_000_000})
{
'chainId': 43114,
'data': "0xa9059cbb00000000000000000000000066ab6d9362d4f35596279692f0251db6351658710000000000000000000000000000000000000000000000008ac7230489e80000",
'gas': 1000000,
'gasPrice': 100000000000,
'to': "0xB31f66AA3C1e785363F0875A1B74E27b85FD66c7",
'value': 0
}
OK, so the full calldata payload for calling a transfer()
of 10 WAVAX to our address is '0xa9059cbb00000000000000000000000066ab6d9362d4f35596279692f0251db6351658710000000000000000000000000000000000000000000000008ac7230489e80000'
Send this to our executor:
>>> tx = executor[0].execute('0xB31f66AA3C1e785363F0875A1B74E27b85FD66c7',"0xa9059cbb00000000000000000000000066ab6d9362d4f35596279692
f0251db6351658710000000000000000000000000000000000000000000000008ac7230489e80000",{'from':accounts[0]})
Transaction sent: 0xca3d67c384b346f8d9748c5fa963c77466b087708c47769ad27e9374473a125e
Gas price: 0.0 gwei Gas limit: 20000000 Nonce: 3
executor.execute confirmed Block: 16268437 Gas used: 39080 (0.20%)
And then view the events:
>>> tx.info()
Transaction was Mined
---------------------
Tx Hash: 0xca3d67c384b346f8d9748c5fa963c77466b087708c47769ad27e9374473a125e
From: 0x66aB6D9362d4F35596279692F0251Db635165871
To: 0x3194cBDC3dbcd3E11a07892e7bA5c3394048Cc87
Value: 0
Function: executor.execute
Block: 16268437
Gas Used: 39080 / 20000000 (0.2%)
Events In This Transaction
--------------------------
└── Wrapped AVAX (0xB31f66AA3C1e785363F0875A1B74E27b85FD66c7)
└── Transfer
├── src: 0x3194cBDC3dbcd3E11a07892e7bA5c3394048Cc87
├── dst: 0x66aB6D9362d4F35596279692F0251Db635165871
└── wad: 10000000000000000000
And now confirm that the WAVAX was successfully transferred to our account from the executor.
>>> wavax.balanceOf(executor[0])
0
>>> wavax.balanceOf(accounts[0])
10000000000000000000
Moving Forward
I have demonstrated that a generalized Vyper contract is possible and useful. Next I’ll extend it to show how to deliver multiple payloads to multiple addresses, then perform some demonstrations of the old “flash borrow to swap” behavior using this generic executor.
This is a critical step before we venture into the hairy world of Flashbots, and it helps us be more flexible on other chains.
Nice!. The simple arbitrage bot was my framework which I started :) But I changed so many things that I just kept the BundleExecutor and FlashBotsUniswapQuery contracts hehe
Thank you! Amazing as always!
I just was discussing this possibility with my teammate to get rid of the burden of upgrading our arbitrage contract for each new dex callback function we want to incorporate... but this seems to solve that precise challenge.
I am intrigued and looking forward to the new flash borrow to swap using this method to see if we can also apply to the callback wrapper functions.