How to use Yul in your MEV project

An A to Z guide of gas cost reduction and using assembly to handle errors, transfer tokens, swap tokens, and more

Solid Quant
21 min readJul 4, 2023
Image Source: BitKeep Academy

So, here’s a recurring story for me. I study a bit of Solidity, find a service that I’d like to look into. The code looks like this:

Seaport Core: BasicOrderFulfiller.sol

Where is Solidity code? People don’t seem to use regular old Solidity code anymore 🥲

This trend towards using lower level code in smart contracts is inevitable, because using assembly can get us closer to the EVM where all the opcodes are ran. (The code snippet above isn’t pure assembly, it’s actually Yul language that can be used alongside Solidity. But I’ll just use the two terms interchangeably.)

Doing this, we can bypass the unnecessary code runs that Solidity enforces on us sometimes, and reduce gas costs as a result. Plus, there are a list of tasks that you can’t perform using Solidity alone, and Yul can help us with that.

Decentralized services try to optimize their code as gas efficient as possible using assembly to give users a better experience.

And for MEV searchers, every transaction that runs, whether it went through or was reverted, will incur gas costs on the runner, and having an optimized Solidity code will save operation costs. So it is crucial that searchers understand the EVM and harness the power of assembly code in Solidity.

I was studying libevm’s subway bot code on Github:

and saw how he used assembly code in his contract’s fallback function (I’ll explain every line of this contract in this post):

// SPDX-License-Identifier: MIT

pragma solidity >=0.8.0;

import "./interface/IERC20.sol";
import "./lib/SafeTransfer.sol";

contract Sandwich {
using SafeTransfer for IERC20;

// Authorized
address internal immutable user;

// transfer(address,uint256)
bytes4 internal constant ERC20_TRANSFER_ID = 0xa9059cbb;

// swap(uint256,uint256,address,bytes)
bytes4 internal constant PAIR_SWAP_ID = 0x022c0d9f;

// Contructor sets the only user
receive() external payable {}

constructor(address _owner) {
user = _owner;
}

// *** Receive profits from contract *** //
function recoverERC20(address token) public {
require(msg.sender == user, "shoo");
IERC20(token).safeTransfer(
msg.sender,
IERC20(token).balanceOf(address(this))
);
}

/*
Fallback function where you do your frontslice and backslice

NO UNCLE BLOCK PROTECTION IN PLACE, USE AT YOUR OWN RISK

Payload structure (abi encodePacked)

- token: address - Address of the token you're swapping
- pair: address - Univ2 pair you're sandwiching on
- amountIn: uint128 - Amount you're giving via swap
- amountOut: uint128 - Amount you're receiving via swap
- tokenOutNo: uint8 - Is the token you're giving token0 or token1? (On univ2 pair)

Note: This fallback function generates some dangling bits
*/
fallback() external payable {
// Assembly cannot read immutable variables
address memUser = user;

assembly {
// You can only access teh fallback function if you're authorized
if iszero(eq(caller(), memUser)) {
// Ohm (3, 3) makes your code more efficient
// WGMI
revert(3, 3)
}

// Extract out teh variables
// We don't have function signatures sweet saving EVEN MORE GAS

// bytes20
let token := shr(96, calldataload(0x00))
// bytes20
let pair := shr(96, calldataload(0x14))
// uint128
let amountIn := shr(128, calldataload(0x28))
// uint128
let amountOut := shr(128, calldataload(0x38))
// uint8
let tokenOutNo := shr(248, calldataload(0x48))

// **** calls token.transfer(pair, amountIn) ****

// transfer function signature
mstore(0x7c, ERC20_TRANSFER_ID)
// destination
mstore(0x80, pair)
// amount
mstore(0xa0, amountIn)

let s1 := call(sub(gas(), 5000), token, 0, 0x7c, 0x44, 0, 0)
if iszero(s1) {
// WGMI
revert(3, 3)
}

// ************
/*
calls pair.swap(
tokenOutNo == 0 ? amountOut : 0,
tokenOutNo == 1 ? amountOut : 0,
address(this),
new bytes(0)
)
*/

// swap function signature
mstore(0x7c, PAIR_SWAP_ID)
// tokenOutNo == 0 ? ....
switch tokenOutNo
case 0 {
mstore(0x80, amountOut)
mstore(0xa0, 0)
}
case 1 {
mstore(0x80, 0)
mstore(0xa0, amountOut)
}
// address(this)
mstore(0xc0, address())
// empty bytes
mstore(0xe0, 0x80)

let s2 := call(sub(gas(), 5000), pair, 0, 0x7c, 0xa4, 0, 0)
if iszero(s2) {
revert(3, 3)
}
}
}
}

Other MEV projects are also doing this, and a lot of them are adopting Huff language as an alternative as well. You can look around and learn more about Huff from here:

But for today’s post, I’d like to try and understand what subway’s Sandwich.sol file is doing under the hood.

I’d also like to compare gas costs of implementing a pure Solidity version of the fallback function and see how much using assembly code can help us reduce gas costs.

I’m doing this because as I first started to learn about the EVM and opcodes, I found very good resources to help me understand the basics, but not many actually gave examples on how to use assembly in real projects.

Table of Contents:

  1. Opcodes used
  2. Storage layout of Sandwich.sol
  3. Using “require” in assembly
  4. Reading calldata using “calldataload” and “shr”
  5. Calling ERC-20 “transfer” function in assembly
  6. Calling Uniswap V2 “swap” function in assembly
  7. Comparing gas costs: Solidity vs. Assembly
  8. What’s next?

Opcodes used

There are a couple of opcodes used in the fallback function.

  • iszero
  • eq
  • caller
  • revert
  • shr
  • calldataload
  • mstore
  • call
  • sub
  • gas
  • sstore (we’ll also learn this opcode along the way)
  • mload (this also)

With just ≥10 opcodes, we can call transfer and swap functions from another contracts, and do basic “require” checks from within our function.

We’ll try to take baby steps and learn these ≥10 opcodes one by one. Some readers may find all these concepts pretty basic, but I tried to keep everything at a beginner’s eye level.

I’ll go over some important concepts like:

  • Storage,
  • Memory,
  • Calldata,
  • Calling functions using assembly,
  • Analyzing smart contracts using Foundry

to get you up to speed with the EVM and assembly language.

The fallback function

Before we begin though, let’s consider why the contract has a single “fallback” function instead of regular function signatures.

This is actually a very neat trick to make your contracts more lightweight and versatile. Fallback functions get executed when a matching function of the call does not exist in the contract.

For instance, someone will send a “swap” call to my contract, but my contract does not have a “swap” function, then this call will default to the “fallback” function.

We’ll see this in action using Foundry and ethers.js.

First, initialize your Foundry project:

forge init subway
cd subway

Within your src directory, create a Solidity file called Sandwich.sol:

// SPDX-License-Identifier: MIT

pragma solidity >=0.8.0;

contract Sandwich {
uint256 public x;

receive() external payable {}

fallback() external payable {
assembly {
let value := calldataload(0x00)
sstore(x.slot, value)
}
}
}

Compile this contract by typing the command:

forge compile

It’s a very simple contract that only has two functions: receive and fallback. The receive function is executed when there is no calldata involved, and the fallback function is executed when a function call to this contract is sent along with a calldata(the data field in transactions). Also, to easily debug our fallback function being called, I’ve added a temporary uint256 value of x. I’ll show you how it’s used below.

I use two opcodes here, which are: calldataload and sstore.

calldataload

When you send along calldata in your transaction, which we’ll see how using Javascript below, it is in the form of a concatenation of 32 byte words.

You can gain access to calldata value 32 bytes at a time using the opcode calldataload by passing in the offset value of where to start reading the calldata. From our Sandwich contract, we can see that we’ve loaded the 0x00(=0) value of calldata. This means that we read a 32 byte word from offset 0.

sstore

Next, we store the value we retrieved from our calldata into our variable x. Our variable x was the first to be defined within our contract, as an uint256 variable — which is 32 bytes. This means that our value x is stored in the 0th slot of our storage. This sounds rather complicated, but it can be visualized using Foundry. Try running:

forge inspect src/Sandwich.sol:Sandwich storage-layout

This will output:

We can see that our variable/label x is in slot 0.

So if we go back to our sstore code, we are storing the value of “value” into the slot of x variable, which is 0.

Now, let’s setup a node project within the same Foundry project directory to start writing our Javascript code. Run:

npm init
npm install ethers@5

Note that I’m using ethers version 5, and not version 6. This is because a lot of the examples online still use this version, so it’s easier to follow along this way.

Create a Javascript file called index.js:

const { ethers } = require('ethers');

const ABI = require('./out/Sandwich.sol/Sandwich.json').abi;
const ADDRESS = '<Address of deployed contract>'; // we'll get this address from below

const calcNextBlockBaseFee = (curBlock) => {
// taken from: https://github.com/libevm/subway/blob/master/bot/src/utils.js
const baseFee = curBlock.baseFeePerGas;
const gasUsed = curBlock.gasUsed;
const targetGasUsed = curBlock.gasLimit.div(2);
const delta = gasUsed.sub(targetGasUsed);

const newBaseFee = baseFee.add(
baseFee.mul(delta).div(targetGasUsed).div(ethers.BigNumber.from(8))
);

// Add 0-9 wei so it becomes a different hash each time
const rand = Math.floor(Math.random() * 10);
return newBaseFee.add(rand);
};

async function main() {
// referenced: https://github.com/libevm/subway/blob/master/bot/index.js

// public, private key generated from Anvil
const PUBLIC = '0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266';
const PRIVATE = '0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80';

const provider = new ethers.providers.JsonRpcProvider('http://127.0.0.1:8545'); // Anvil RPC
const wallet = new ethers.Wallet(PRIVATE, provider);
const { chainId } = await provider.getNetwork();

const sandwich = new ethers.Contract(ADDRESS, ABI, wallet);

// before call
let x = await sandwich.x();
console.log(`Before: ${x.toString()}`);

// send transaction
const block = await provider.getBlock();
const nextBaseFee = calcNextBlockBaseFee(block);
const nonce = await wallet.getTransactionCount();
// you don't need a function signature to call fallback function
const payload = ethers.utils.solidityPack(
['uint256'],
[10]
);
console.log(payload);
const tx = {
to: ADDRESS,
from: PUBLIC,
data: payload,
chainId,
maxPriorityFeePerGas: 0,
maxFeePerGas: nextBaseFee,
gasLimit: 250000,
nonce,
type: 2,
};
const signed = await wallet.signTransaction(tx);
const res = await provider.sendTransaction(signed);
const receipt = await provider.getTransactionReceipt(res.hash);
console.log(receipt.gasUsed.toString());

// after call
x = await sandwich.x();
console.log(`After: ${x.toString()}`);
}

(async () => {
await main();
})();

We just need to focus on the main function. We get the value of x before we call the fallback function. Then we send a transaction that does not have a function name that we want to call, instead we simply encode the values we want to send to our contract with ethers.utils.solidityPack. This will result in:

0x000000000000000000000000000000000000000000000000000000000000000a

We then sign the raw transaction, and check that our x value has changed to 10.

To run this code, I’m going to use Anvil, a local testnet node like Ganache. If you have Foundry installed, you’ll also have Anvil. Run the below command by starting up another terminal:

anvil

This will startup your local testnet using Anvil:

This is it for the whole setup to run our code.

We’ll first deploy our Sandwich contract:

forge create --rpc-url http://127.0.0.1:8545 --private-key 0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80 src/Sandwich.sol:Sandwich

We get an output that looks like this:

Copy the Deployed to part from the output, for me it is: 0xB7f8BC63BbcaD18155201308C8f3540b07f84F5e

Paste this value into our Javascript code. Replace the below code snippet with our deployed contract’s address:

const ADDRESS = '0xB7f8BC63BbcaD18155201308C8f3540b07f84F5e'; // we'll get this address from below

Next, we run our Javascript code:

node index.js

And we successfully ran our fallback function:

Now that the code setup is complete, let’s dive into the original fallback function in depth.

Storage layout of Sandwich.sol

First, let’s create a bare minimum code of Sandwich.sol as below:

// SPDX-License-Identifier: MIT

pragma solidity >=0.8.0;

import "./IERC20.sol";
import "./SafeTransfer.sol";

contract Sandwich {
using SafeTransfer for IERC20;

address internal immutable user;
bytes4 internal constant ERC20_TRANSFER_ID = 0xa9059cbb;
bytes4 internal constant PAIR_SWAP_ID = 0x022c0d9f;

receive() external payable {}

constructor(address _owner) {
user = _owner;
}

function recoverERC20(address token) public {
require(msg.sender == user, "shoo");
IERC20(token).safeTransfer(
msg.sender,
IERC20(token).balanceOf(address(this))
);
}

fallback() external payable {}
}

This is everything in Sandwich.sol but for the fallback function. And there are 3 variables defined in total: address, byte4, bytes4.

Let’s check the storage layout of this contract:

forge inspect src/Sandwich.sol:Sandwich storage-layout

We get:

{“storage”: [], “types”: {}}

That’s strange. However, since these variables are all immutable or constant, they don’t get saved into storage, but are rather replaced from the code in every place these variables are used with these hardcoded values during compilation.

This can help save gas costs, because storage operations are some of the most expensive Ethereum operations to watch out for when interacting with smart contracts.

We understand the storage layout of our contract. And we also know when we could use fallback functions. I think now we are ready to fill our fallback function with the core logic.

Using “require” in assembly

Let’s start with “require”. This one is simple. To be exact though, assembly doesn’t support the equivalent of a “require”, so we will have to make do with a “revert”.

Because all that “revert” does is to check for a condition to be met, and if that condition doesn’t hold, we revert the whole transaction undoing any state changes that were made. We’ll have to start with a conditional check, then call the revert opcode explicitly.

Let’s zoom in on the fallback function, we’ll fill it up like below:

fallback() external payable {
address memUser = user;

assembly {
if iszero(eq(caller(), memUser)) {
revert(0, 0) // the same as revert(3, 3)
}
}
}

Sadly, we can’t access immutable variables using assembly, so we set the address of user as memUser from outside the scope of assembly. Then, we start the assembly block.

Here, we use four new opcodes, which are pretty straightforward to understand:

  • iszero: this returns true if the value given is 0
  • eq: this checks if the two arguments given as function parameters have the same value
  • caller: the caller of the function (equivalent of msg.sender)
  • revert: reverts the transaction

This code is both easy and efficient, however, we may need more information regarding errors. So we could change the code like below:

error NotOwner();

fallback() external payable {
address memUser = user;

assembly {
if iszero(eq(caller(), memUser)) {
let errorPtr := mload(0x40)
mstore(
errorPtr,
0x30cd747100000000000000000000000000000000000000000000000000000000
)
revert(errorPtr, 0x4)
}
}
}

I’ve added a definition for an error called NotOwner, and within the assembly code block loaded up data from memory using mload.

Next, within that memory space added the first 4 bytes(=8 characters) of the selector of NowOwner (30cd74712f59d478562d48e2d35de830db72c60a63dd08ae59199eec990b5bc4). You can check this for yourself from below:

Then, I used “revert” again, but this time returning the error signature stored within errorPtr.

We can retrieve the error message using ethers.js:

const signed = await wallet.signTransaction(tx);
const res = await provider.sendTransaction(signed);
const receipt = await provider.getTransactionReceipt(res.hash);

const code = await wallet.call(
{
data: res.data,
to: res.to
}
);
console.log(sandwich.interface.parseError(code));

/*
ErrorDescription {
args: [],
errorFragment: {
type: 'error',
name: 'NotOwner',
inputs: [],
_isFragment: true,
constructor: [Function: ErrorFragment] {
from: [Function (anonymous)],
fromObject: [Function (anonymous)],
fromString: [Function (anonymous)],
isErrorFragment: [Function (anonymous)]
},
format: [Function (anonymous)]
},
name: 'NotOwner',
signature: 'NotOwner()',
sighash: '0x30cd7471'
}
*/

So what’s a better reverting method? I think it all depends on your preferences. The difference between the two is essentially in readability and gas cost: The cost of revert(0, 0) is 21255, and the cost of the latter is 21282. Not a very big difference. (I’ll go with the simpler method.)

Reading calldata using “calldataload” and “shr”

Let’s try reading calldata that is packed with multiple variable values.

fallback() external payable {
address memUser = user;

assembly {
if iszero(eq(caller(), memUser)) {
revert(0, 0) // the same as revert(3, 3)
}

let token := shr(96, calldataload(0x00))
let pair := shr(96, calldataload(0x14))
let amountIn := shr(128, calldataload(0x28))
let amountOut := shr(128, calldataload(0x38))
let tokenOutNo := shr(248, calldataload(0x48)) // I'll explain what this is from 'Calling Uniswap V2 "swap" function in assembly'
}
}

To understand the opcodes used to parse calldata, we should first see how the calldata is passed. We’ll do this using Javascript as below:

const payload = ethers.utils.solidityPack(
['address', 'address', 'uint128', 'uint128', 'uint8'],
[
'0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266',
'0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266',
1,
2,
3
]
);
console.log(payload);

To make our lives easier, I plugged in values that are simple to deal with in hex. The resulting value looks like this:

0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266f39fd6e51aad88f6f4ce6ab8827279cfffb92266000000000000000000000000000000010000000000000000000000000000000203

I’ll try to make this more human readable:

  1. 0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266 (20 bytes)

2. f39fd6e51aad88f6f4ce6ab8827279cfffb92266 (20 bytes)

3. 00000000000000000000000000000001 (16 bytes = 128 bits)

4. 00000000000000000000000000000002 (16 bytes = 128 bits)

5. 03 (1 byte = 8 bits)

We can see that the concatenated variables differ in length because of their size, however, we saw from above that “calldataload” can only read in 32 byte words at a time.

shr

That’s why we need to use “shr” opcode to bit shift our data to fit our needs. We’ll investigate this further.

The first command attempts to retrieve “token” data. This will load 32 bytes of calldata from the 0th byte index, that is the equivalent of 64 characters.

However, we only need the first 20 bytes of calldata, because addresses are of type bytes20. We achieve this by using “shr”.

Shifting the value by 96 bits (=12 bytes), we are left with just the data we need.

The same logic applies to the other commands:

  • pair: read calldata from 20th byte index (=0x14 in decimal), shift data by 96 bits,
  • amountIn: read calldata from 40th byte index (=0x28), shift data by 128 bits,
  • amountOut: read calldata from 56th byte index (=0x38), shift data by 128 bits,
  • tokenOutNo: read calldata from 72th byte index (=0x48), shift data by 248 bits

Calling ERC-20 “transfer” function in assembly

We are now going to call ERC-20’s transfer function with assembly. We will quickly look at how the “transfer” function is called:

We can see that the “transfer” function takes in two variables: to and amount. Let’s write up our assembly code right away:

fallback() external payable {
address memUser = user;

assembly {
// owner check
if iszero(eq(caller(), memUser)) {
revert(0, 0)
}

// read calldata
let token := shr(96, calldataload(0x00))
let pair := shr(96, calldataload(0x14))
let amountIn := shr(128, calldataload(0x28))
let amountOut := shr(128, calldataload(0x38))
let tokenOutNo := shr(248, calldataload(0x48))

// call transfer
mstore(0x7c, ERC20_TRANSFER_ID)
mstore(0x80, pair)
mstore(0xa0, amountIn)

let s1 := call(sub(gas(), 5000), token, 0, 0x7c, 0x44, 0, 0)
if iszero(s1) {
revert(0, 0)
}
}
}

mstore

We call “mstore(x, y)” to store value y into location x in memory.

First, we are storing the 4 bytes selector of “transfer” which is 0xa9059cbb into memory location 0x7c (=124 in decimal). After this data has been written, we can start storing the next data 4 bytes after 0x7c, which is 0x80 (=128 in decimal).

This time, we store the pair address in memory location 0x80. This will take up 32 bytes, so the next argument can go into memory location 0xa0 (=160 in decimal).

The same goes for amountIn. Starting from 0xa0, we store the 32 bytes value into memory.

call

You may be wondering why we had to store the function selector and the arguments into memory before we could perform a function call. This is because the EVM is structured to use memory for tasks such as returning from external calls, setting function values for external calls, etc.

With this in mind, it isn’t too difficult to call external function calls using the “call” opcode.

call(g, a, v, in, insize, out, outsize): is the opcode used to call a contract at address a, with gas amount g, passing v wei as msg.value, passing tx.data location starting from in as much as insize bytes, and storing the returned data in memory location starting from out as much as outsize bytes. Also, this opcode call will return 1 if the call was successful, and 0 otherwise.

We view our code again:

let s1 := call(sub(gas(), 5000), token, 0, 0x7c, 0x44, 0, 0)

gas: the amount of gas that is still available for execution

We use gas() - 5000 to call “transfer” function to the token contract, with a calldata that is structured as: [selector][pair][amountIn]. The calldata will be retrieved from memory location 0x7c by 0x44 bytes (= 68 bytes = 4 bytes for selector + 32 bytes for address + 32 bytes for uint256). This call won’t return any values, thus, we pass in two 0s for out, outsize.

Calling Uniswap V2 “swap” function in assembly

This part should be a breeze, because it’s essentially the equivalent of calling “transfer” function as we did from the previous section.

Before we write out our “swap” call in assembly, let’s go to Uniswap V2 core contracts, and get a glimpse of what function we are calling:

Quite complicated, but all we need to know is that we should have input relevant tokens into the pair contract, then we can get back amountOut of the other token as a result.

fallback() external payable {
address memUser = user;

assembly {
// owner check
if iszero(eq(caller(), memUser)) {
revert(0, 0)
}

// read calldata
let token := shr(96, calldataload(0x00))
let pair := shr(96, calldataload(0x14))
let amountIn := shr(128, calldataload(0x28))
let amountOut := shr(128, calldataload(0x38))
let tokenOutNo := shr(248, calldataload(0x48))

// call transfer
mstore(0x7c, ERC20_TRANSFER_ID)
mstore(0x80, pair)
mstore(0xa0, amountIn)

let s1 := call(sub(gas(), 5000), token, 0, 0x7c, 0x44, 0, 0)
if iszero(s1) {
revert(0, 0)
}

// call swap
mstore(0x7c, PAIR_SWAP_ID)
switch tokenOutNo
case 0 {
mstore(0x80, amountOut)
mstore(0xa0, 0)
}
case 1 {
mstore(0x80, 0)
mstore(0xa0, amountOut)
}
mstore(0xc0, address())
mstore(0xe0, 0x80)

let s2 := call(sub(gas(), 5000), pair, 0, 0x7c, 0xa4, 0, 0)
if iszero(s2) {
revert(0, 0)
}
}
}

First, we store the selector of function “swap” which is 0x022c0d9f into memory location 0x7c.

Next, we check if tokenOutNo is 0 or 1. If tokenOutNo is 0, this means that the input token we sent to the pair from the previous “transfer” section was token 1 and that we would like to get amountOut of token 0 in return.

So, if tokenOutNo is 0, we store amountOut into 0x80, and 0 into 0xa0. And the other way around if tokenOutNo is 1.

Next, we store the address of this contract into memory location 0xc0, then empty bytes into 0xe0.

Lastly, we call “swap” by doing:

let s2 := call(sub(gas(), 5000), pair, 0, 0x7c, 0xa4, 0, 0)

This function call has a calldata of 164 bytes, which is the sum of:

  • selector: 4 bytes
  • uint256: 32 bytes
  • uint256: 32 bytes
  • address: 32 bytes
  • empty bytes: 64 bytes

Comparing gas costs: Solidity vs. Assembly

Finally, I write the Solidity version of this fallback function called “swap”. Below is the complete code for our Sandwich.sol:

// SPDX-License-Identifier: MIT

pragma solidity >=0.8.0;

import "./IERC20.sol";
import "./SafeTransfer.sol";

interface IUniswapV2Pair {
function swap(
uint amount0Out,
uint amount1Out,
address to,
bytes calldata data
) external;
}

contract Sandwich {
using SafeTransfer for IERC20;

address internal immutable user;
bytes4 internal constant ERC20_TRANSFER_ID = 0xa9059cbb;
bytes4 internal constant PAIR_SWAP_ID = 0x022c0d9f;

receive() external payable {}

constructor(address _owner) {
user = _owner;
}

function recoverERC20(address token) public {
// same code here...
}

function swap(
address token,
address pair,
uint128 amountIn,
uint128 amountOut,
uint8 tokenOutNo
) external payable {
require(msg.sender == user, "Not the owner");
IERC20(token).transfer(pair, amountIn);
if (tokenOutNo == 0) {
IUniswapV2Pair(pair).swap(amountOut, 0, address(this), "");
} else {
IUniswapV2Pair(pair).swap(0, amountOut, address(this), "");
}
}

fallback() external payable {
// same code here...
}
}

Everything remains untouched except for the additional function called “swap” I’ve added along with the IUniswapV2Pair interface.

To test this on the mainnet and compare gas costs of the two function calls, I’ll hardfork the mainnet using Foundry. This process lets you use the mainnet state from your local machine, but not download any remote data. Hardforking the mainnet is a useful way of testing your function calls, because this will enable you to test your function calls against real Ethereum states.

You will basically have access to all the protocols that are already running on the Ethereum mainnet, so the range of tests you can run become endless and will be more realistic than testing your contracts on Anvil local testnet. Hardforking won’t copy all the states to your local machine, so it neither takes a long time nor costs you anything.

You can reference Foundry book for this:

Hardforking with Anvil is very easy:

anvil --fork-url <RPC_ENDPOINT_OF_YOUR_CHOICE>

Run this command with an RPC endpoint of your choice and you’re all set.

Next, we have to deploy our final Sandwich contract to Anvil mainnet hardfork:

forge create --rpc-url http://127.0.0.1:8545 --constructor-args 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266 --private-key 0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80 src/Sandwich.sol:Sandwich

The response I got was:

[⠰] Compiling...
No files changed, compilation skipped
Deployer: 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266
Deployed to: 0xE2b5bDE7e80f89975f7229d78aD9259b2723d11F
Transaction hash: 0x2038f9c7a09037d1ed64d7b93cf7827060ab24ae497c12084bd3a6c086f3df71

Copy: 0xE2b5bDE7e80f89975f7229d78aD9259b2723d11F

Then create a Javascript file for testing:

const { ethers } = require('ethers');

const SANDWICH_ADDRESS = '0xE2b5bDE7e80f89975f7229d78aD9259b2723d11F';
const WETH_ADDRESS = '0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2';
const USDT_ADDRESS = '0xdAC17F958D2ee523a2206206994597C13D831ec7';
const WETH_USDT_PAIR_ADDRESS = '0x0d4a11d5EEaaC28EC3F61d100daF4d40471f1852';
const WETH_TOKEN_0 = 1;
const DECIMALS = {
WETH: 18,
USDT: 6
};

const SANDWICH_ABI = require('./out/Sandwich.sol/Sandwich.json').abi; // ABI returned from Foundry compile
const WETH_ABI = require('./weth.json'); // I got the ABI from Etherscan

const calcNextBlockBaseFee = (curBlock) => {
const baseFee = curBlock.baseFeePerGas;
const gasUsed = curBlock.gasUsed;
const targetGasUsed = curBlock.gasLimit.div(2);
const delta = gasUsed.sub(targetGasUsed);

const newBaseFee = baseFee.add(
baseFee.mul(delta).div(targetGasUsed).div(ethers.BigNumber.from(8))
);

// Add 0-9 wei so it becomes a different hash each time
const rand = Math.floor(Math.random() * 10);
return newBaseFee.add(rand);
};

async function main() {
const PUBLIC = '0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266';
const PRIVATE = '0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80';

const provider = new ethers.providers.JsonRpcProvider('http://127.0.0.1:8545'); // anvil
const wallet = new ethers.Wallet(PRIVATE, provider);
const { chainId } = await provider.getNetwork();

// SETUP: create contract instances
const sandwich = new ethers.Contract(SANDWICH_ADDRESS, SANDWICH_ABI, wallet);
const weth = new ethers.Contract(WETH_ADDRESS, WETH_ABI, wallet);
const usdt = new ethers.Contract(USDT_ADDRESS, WETH_ABI, wallet);

////////////////////////
// STEP 1: Wrap 1 ETH //
////////////////////////
console.log('\n===== Wrapping ETH =====');

let wethBalance = await weth.balanceOf(PUBLIC);
console.log('- WETH balance before: ', wethBalance.toString());

// simply send 2 ETH to WETH contract
await wallet.sendTransaction({
to: WETH_ADDRESS,
value: ethers.utils.parseEther('2'),
});

wethBalance = await weth.balanceOf(PUBLIC);
console.log('- WETH balance after: ', wethBalance.toString());

///////////////////////////////////////////////////////////////////////////////
// STEP 2: Transfer WETH to Sandwich contract so we can use it on Uniswap V2 //
///////////////////////////////////////////////////////////////////////////////
console.log('\n===== Transferring WETH =====');

let calldata = weth.interface.encodeFunctionData(
'transfer',
[
SANDWICH_ADDRESS,
ethers.utils.parseUnits('1', DECIMALS.WETH),
]
);
let signedTx = await wallet.signTransaction({
to: WETH_ADDRESS, // call transfer on WETH
from: PUBLIC,
data: calldata,
chainId,
maxPriorityFeePerGas: 0,
maxFeePerGas: calcNextBlockBaseFee(await provider.getBlock()),
gasLimit: 3000000,
nonce: await wallet.getTransactionCount(),
type: 2,
});
let txResponse = await provider.sendTransaction(signedTx);
let receipt = await provider.getTransactionReceipt(txResponse.hash);
// console.log('- WETH transfer gas used: ', receipt.gasUsed.toString());

wethBalance = await weth.balanceOf(SANDWICH_ADDRESS);
console.log('- WETH balance before swap: ', wethBalance.toString());

let usdtBalance = await usdt.balanceOf(SANDWICH_ADDRESS);
console.log('- USDT balance before swap: ', usdtBalance.toString());

//////////////////////////////////////////////////////////
// STEP 3: Calling "swap" function on Sandwich contract //
//////////////////////////////////////////////////////////
console.log('\n===== Calling Swap =====');

calldata = sandwich.interface.encodeFunctionData(
'swap',
[
WETH_ADDRESS,
WETH_USDT_PAIR_ADDRESS,
ethers.utils.parseUnits('0.5', DECIMALS.WETH),
ethers.utils.parseUnits('950', DECIMALS.USDT), // the current rate is 976, change accordingly
WETH_TOKEN_0 ? 1 : 0, // out token is 1 if WETH is token 0
]
);
signedTx = await wallet.signTransaction({
to: SANDWICH_ADDRESS, // calling swap on Sandwich
from: PUBLIC,
data: calldata,
chainId,
maxPriorityFeePerGas: 0,
maxFeePerGas: calcNextBlockBaseFee(await provider.getBlock()),
gasLimit: 3000000,
nonce: await wallet.getTransactionCount(),
type: 2,
});
txResponse = await provider.sendTransaction(signedTx);
receipt = await provider.getTransactionReceipt(txResponse.hash);
console.log('- Swap gas used: ', receipt.gasUsed.toString());

wethBalance = await weth.balanceOf(SANDWICH_ADDRESS);
console.log('- WETH balance after swap: ', wethBalance.toString());

usdtBalance = await usdt.balanceOf(SANDWICH_ADDRESS);
console.log('- USDT balance after swap: ', usdtBalance.toString());

////////////////////////////////////////////////////////////
// STEP 4: Calling fallback function on Sandwich contract //
////////////////////////////////////////////////////////////
console.log('\n===== Calling Fallback =====');

calldata = ethers.utils.solidityPack(
['address', 'address', 'uint128', 'uint128', 'uint8'],
[
WETH_ADDRESS,
WETH_USDT_PAIR_ADDRESS,
ethers.utils.parseUnits('0.5', DECIMALS.WETH),
ethers.utils.parseUnits('950', DECIMALS.USDT),
WETH_TOKEN_0 ? 1 : 0,
]
);
signedTx = await wallet.signTransaction({
to: SANDWICH_ADDRESS,
from: PUBLIC,
data: calldata,
chainId,
maxPriorityFeePerGas: 0,
maxFeePerGas: calcNextBlockBaseFee(await provider.getBlock()),
gasLimit: 3000000,
nonce: await wallet.getTransactionCount(),
type: 2,
});
txResponse = await provider.sendTransaction(signedTx);
receipt = await provider.getTransactionReceipt(txResponse.hash);
console.log('- Assembly gas used: ', receipt.gasUsed.toString());

wethBalance = await weth.balanceOf(SANDWICH_ADDRESS);
console.log('- WETH balance after swap: ', wethBalance.toString());

usdtBalance = await usdt.balanceOf(SANDWICH_ADDRESS);
console.log('- USDT balance after swap: ', usdtBalance.toString());
}

(async () => {
await main();
})();

This is a long script, but written in a way that is easy to follow. This script contains four steps, which are:

  1. Wrap 2 Ether,
  2. 2. Transfer 1 WETH to Sandwich contract,
  3. 3. Swap 0.5 WETH to USDT using Solidity version of “swap”,
  4. 4. Swap 0.5 WETH to USDT using Yul version of “swap”

The end result is interesting:

As we can see, calling the Solidity version of swap costs 100765 gas, whereas the assembly version costs 99373. There is an improvement over gas costs.

What’s next?

This post was a long one that dealt with using assembly in MEV trading. We saw that using assembly can make our contracts gas efficient.

In the following posts, I’ll,

  1. Build a simple MEV bot using Python, Javascript, Golang, and Rust, then try running them all at the same time and see if language differences contribute to performance boosts.
  2. Build a simple transaction simulator using REVM. This can help understand how Foundry works under the hood, and help build a highly optimized simulation engine for our MEV bot.
  3. Try and understand ApeBot written by MevAlphaLeak (https://remix.ethereum.org/#address=0x666f80a198412bcb987c430831b57ad61facb666)
  4. Build a simple CEX-DEX arbitrage bot that backruns price affecting transactions.

I’m currently working on all four projects at the same time and don’t know which one I’ll finish first. I know I have to focus on one project at a time, but I think I’m wired to multitask… Oh well.

I’ll see you soon! Thank you for reading 😃

Below are some amazing sources I used to study EVM and assembly.

References:

--

--