Degen Code

Degen Code

Share this post

Degen Code
Degen Code
Smart Contract Arbitrage — Calldata Pass-through, ABI Encode / Decode, Flexible Fallback
Copy link
Facebook
Email
Notes
More

Smart Contract Arbitrage — Calldata Pass-through, ABI Encode / Decode, Flexible Fallback

Take a Byte

Jul 02, 2022
∙ Paid
7

Share this post

Degen Code
Degen Code
Smart Contract Arbitrage — Calldata Pass-through, ABI Encode / Decode, Flexible Fallback
Copy link
Facebook
Email
Notes
More
6
Share

As I completed my exploration of uniswapV2-style atomic arbitrage in March, I wrote a fairly intricate smart contract that implements the joeCall() callback for arbitrage through TraderJoe liquidity pools. The same contract could be used to implement the uniswapV2Call() callback for SushiSwap, and pangolinCall() for Pangolin.

I’m always learning, so even if I stop writing about a subject for a time you can be sure I’m still watching for ways to improve.

Last month I found a very interesting post called The 0 to 1 Guide for MEV by well-known searcher 0xmebius. There’s a ton of great info in there, so I highly recommend reading through it.

One point in particular stood out to me:

NEVER use storage. Access variables through memory and calldata only. Storage reads and writes are excruciatingly expensive gas wise and are almost always unnecessary.

I wasn’t sure immediately how to implement this advice, so I went back to the UniswapV2Pair contract on GitHub and re-read. One thing stood out to me that I had not noticed.

# LINE 172

if (data.length > 0) IUniswapV2Callee(to).uniswapV2Call(
    msg.sender, 
    amount0Out, 
    amount1Out, 
    data
);

I’ve written about this line before, but only to highlight the importance of the call to swap() receiving an appropriate input for data that would trigger the callback function. I simply set it to b'x' and went on my merry way, unaware that it could be used in very clever ways.

I never noticed that the input calldata was actually passed back to the calling contract!

I’m pleased to report that this is precisely the method that allows us to eliminate the use of storage variables. It took a lot of hacking around and learning about EVM, but we’ve done it fam.

Calldata

What the hell is calldata anyway? I explored it a bit in the previous two posts about the single-use and multi-payload bundle executor. Now let’s do a deeper dive to figure out what it is, how it’s built, and how we can manipulate it.

I’ve pieced a lot of this stuff together by reading through these resources:

  • Diving Into the Ethereum Virtual Machine (Part 1)

  • Diving Into the Ethereum Virtual Machine (Part 2)

  • Diving Into the Ethereum Virtual Machine (Part 3)

  • Diving Into the Ethereum Virtual Machine (Part 4)

  • Contract ABI Specification (Solidity)

I won’t attempt to cover everything here because it’s just too dense, but here is the high level summary:

Calldata is a long string of bytes with a predictable, deterministic structure. We are used to seeing familiar sounding names (functions) with inputs (named arguments with values). However EVM does not see any of that. It simply sees a signed transaction sent to an address, with calldata. The calldata allows EVM to determine which function is being executed and the data formats and order of inputs for that function.

Take the WAVAX deposit() example, where this:

deposit()

becomes

'0xd0e30db0'

Calldata has a predictable format. It always begins with a 4-byte function selector. The function selector determined by taking the keccak256 hash of the function prototype (the name of the function with all input types, without names), and trimming all but the first 4 bytes.

As another example, let’s take the uniswapV2Call(). To obtain the function selector, transform this

uniswapV2Call(address,uint256,uint256,bytes)

into

'0x10d1e85c'

Encoding Calldata

As for the calldata portion, we can use a very helpful Python module called eth_abi. I’ve used this previously to decode calldata, but it can be used to encode it as well.

Install it with pip install eth_abi, then open a console to try it out.

Here let’s try encoding the calldata for uniswapV2Call using the following values:

  • address = 0x3194cBDC3dbcd3E11a07892e7bA5c3394048Cc87

  • uint256 = 1000000

  • uint256 = 0

  • bytes = b''

>>> import eth_abi
>>> eth_abi.encode_single(
    '(address,uint256,uint256,bytes)',  
    (
        '0x3194cBDC3dbcd3E11a07892e7bA5c3394048Cc87',
        1_000_000,
        0,
        b'bowtieddevil'
    )
).hex()

'0000000000000000000000003194cbdc3dbcd3e11a07892e7ba5c3394048cc8700000000000000000000000000000000000000000000000000000000000f424000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000080000000000000000000000000000000000000000000000000000000000000000c626f7774696564646576696c0000000000000000000000000000000000000000'

The length of this byte string is 384 characters (192 bytes, since a hex byte is represented by two characters). 192 is a perfect multiple of 32 (32 x 6 = 192).

You’ll see 32 bytes everywhere once you start digging into calldata. 32 bytes (equal to 256 bits) is the “word size” for the EVM, which means that all data structures and stack registers are 32 bytes wide. By default, all stored data structures shorter than 32 bytes are “padded” with extra zeroes to fit inside the word, and all data structures longer than 32 bytes are broken into 32 byte chunks.

The process of building calldata for a series of different inputs is called serialization.

Here are the 32 byte chunks of our calldata:

  1. 0000000000000000000000003194cbdc3dbcd3e11a07892e7ba5c3394048cc87

  2. 00000000000000000000000000000000000000000000000000000000000f4240

  3. 0000000000000000000000000000000000000000000000000000000000000000

  4. 0000000000000000000000000000000000000000000000000000000000000080

  5. 000000000000000000000000000000000000000000000000000000000000000c

  6. 626f7774696564646576696c0000000000000000000000000000000000000000

Chunk #1 should look familiar. It’s a 32-byte padded version of our address.

Chunk #2 is the hexadecimal representation of the number 1,000,000. You can verify it by entering int('00000000000000000000000000000000000000000000000000000000000f4240', 16) in Python.

Chunk #3 is the hexadecimal representation of the number 0.

Chunk #4 is a marker for the “dynamic” data bytes data type. A dynamic type may fit inside a 32 byte word, but it might not, depending on the length of the input. These structures begin with a 32 byte “offset” value where a single-word data type would appear in the calldata. Additional 32 byte words are appended at the offset value that specify:

  • The length of the value

  • The value itself

Chunks 4 through 6 are:

  • 4: offset = 128

  • 5: length = 12

  • 6: value = '626f7774696564646576696c' (with end-padding to 32 bytes as needed)

You can convert the value of 626f7774696564646576696c to an alphanumeric string similarly:

>>> bytes.fromhex('626f7774696564646576696c0000000000000000000000000000000000000000')

b'bowtieddevil\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00'

Working With Calldata in Vyper

Before proceeding, please note that a critical function _abi_decode() is not available in Vyper 0.3.3 but was added three weeks ago. It will be part of version 0.3.4, which is not yet released.

Version 0.3.4 has some bugs that will be ironed out, but nothing that keeps us from using it now inside a virtual environment.

I recommend installing the latest Vyper inside a dedicated virtual environment using pip install git+https://github.com/vyperlang/vyper

Confirm that it’s installed with vyper --version and then let’s build a new contract!

The Plan

We will build a contract that performs these steps:

  • Initiate our flash borrow arbitrage with a function that accepts an array of payloads with format (address, calldata byte string, message value)

  • Encodes a byte string using _abi_encode() that will be processed later

  • Calls the UniswapV2Pair contract swap() function with the encoded byte string

  • Inside the callback function, use _abi_decode() to pull out the input parameters from the pair contract, and the original encoded byte string

  • Decode the byte string into the original payload format (address, bytes, message value) values

  • Feed those payloads into raw_call()

  • Profit!

This post is for paid subscribers

Already a paid subscriber? Sign in
© 2025 BowTiedDevil
Privacy ∙ Terms ∙ Collection notice
Start writingGet the app
Substack is the home for great culture

Share

Copy link
Facebook
Email
Notes
More