指南: 如何在 MEV 项目中使用 Yul
这对我来说是一个反复出现的故事。我学习了一些 Solidity,发现了一个我想要研究的服务。代码看起来是这样的:
Seaport Core: BasicOrderFulfiller.sol
Solidity 代码在哪里?人们似乎不再使用普通的 Solidity 代码了 🥲
这种在智能合约中使用底层代码的趋势是不可避免的,因为使用汇编可以让我们更接近 EVM,所有的操作码都是在这里运行的。(上面的代码片段并不是纯汇编,它实际上是可以与 Solidity 一起使用的 Yul 语言。但我会将这两个术语互换使用。)
这样做,我们可以绕过 Solidity 有时强加给我们的不必要的代码运行,从而降低 gas 成本。此外,还有一些任务是仅使用 Solidity 无法执行的,而 Yul 可以帮助我们完成这些任务。
去中心化服务试图尽可能使用汇编优化他们的代码,以便为用户提供更好的体验。
对于 MEV 搜索者来说,每一笔交易,无论是成功还是被撤回,都会在运行者身上产生 gas 成本,优化的 Solidity 代码将节省运营成本。因此,搜索者理解 EVM 并利用 Solidity 中汇编代码的力量至关重要。
并看到他在合约的 fallback 函数中使用了汇编代码(我将在这篇文章中解释这个合约的每一行):
// SPDX-License-Identifier: MIT
pragma solidity >=0.8.0;
import "./interface/IERC20.sol";
import "./lib/SafeTransfer.sol";
contract Sandwich {
using SafeTransfer for IERC20;
// 授权
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;
// 构造函数设置唯一用户
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))
);
}
/*
回退函数,你可以在这里进行前切片和后切片
没有叔块保护,使用风险自负
负载结构 (abi encodePacked)
- token: address - 你要交换的代币地址
- pair: address - 你在其上进行夹击的 Univ2 对
- amountIn: uint128 - 你通过交换提供的数量
- amountOut: uint128 - 你通过交换接收的数量
- tokenOutNo: uint8 - 你提供的代币是 token0 还是 token1? (在 univ2 对上)
注意:此回退函数会生成一些悬挂位
*/
fallback() external payable {
// 汇编无法读取不可变变量
address memUser = user;
assembly {
// 只有在你被授权的情况下才能访问回退函数
if iszero(eq(caller(), memUser)) {
// Ohm (3, 3) 使你的代码更高效
// WGMI
revert(3, 3)
}
// 提取变量
// 我们没有函数签名,节省更多 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))
// **** 调用 token.transfer(pair, amountIn) ****
// 转移函数签名
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) {
// WGMI
revert(3, 3)
}
// ***************
/*
调用 pair.swap(
tokenOutNo == 0 ? amountOut : 0,
tokenOutNo == 1 ? amountOut : 0,
address(this),
new bytes(0)
)
*/
// 交换函数签名
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())
// 空字节
mstore(0xe0, 0x80)
let s2 := call(sub(gas(), 5000), pair, 0, 0x7c, 0xa4, 0, 0)
if iszero(s2) {
revert(3, 3)
}
}
}
}
其他 MEV 项目也在这样做,很多项目也在采用 Huff 语言作为替代。你可以在这里了解更多关于 Huff 的信息:
但在今天的文章中,我想尝试理解 subway 的 Sandwich.sol 文件在背后做了什么。
我还想比较实现纯 Solidity 版本的回退函数的 gas 成本,看看使用汇编代码能帮助我们减少多少 gas 成本。
我这样做是因为当我刚开始学习 EVM 和操作码时,我发现了一些很好的资源来帮助我理解基础知识,但实际上没有多少资源给出了如何在真实项目中使用汇编的示例。
目录:
- 使用的操作码
- Sandwich.sol 的存储布局
- 在汇编中使用“require”
- 使用“calldataload”和“shr”读取 calldata
- 在汇编中调用 ERC-20 “transfer” 函数
- 在汇编中调用 Uniswap V2 “swap” 函数
- 比较 gas 成本:Solidity vs. Assembly
- 接下来是什么?
使用的操作码
在回退函数中使用了几个操作码。
- iszero
- eq
- caller
- revert
- shr
- calldataload
- mstore
- call
- sub
- gas
- sstore (我们也将在此过程中学习这个操作码)
- mload (这个也是)
通过使用至少 10 个操作码,我们可以从其他合约调用转账和交换函数,并在我们的函数内部进行基本的“require”检查。
我们将尝试逐步学习这 10 个操作码。一些读者可能会觉得这些概念相当基础,但我尽量将所有内容保持在初学者的视角。
我将介绍一些重要的概念,例如:
- 存储,
- 内存,
- 调用数据,
- 使用汇编调用函数,
- 使用 Foundry 分析智能合约
以帮助你快速了解 EVM 和汇编语言。
回退函数
在我们开始之前,让我们考虑一下为什么合约只有一个“回退”函数,而不是常规的函数签名。
这实际上是一个非常巧妙的技巧,可以使你的合约更加轻量和灵活。当调用的匹配函数在合约中不存在时,回退函数会被执行。
例如,有人会向我的合约发送一个“交换”调用,但我的合约没有“交换”函数,那么这个调用将默认转到“回退”函数。
我们将使用 Foundry 和 ethers.js 来查看这一点。
首先,初始化你的 Foundry 项目:
forge init subway
cd subway
在你的 src 目录中,创建一个名为 Sandwich.sol 的 Solidity 文件:
// 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)
}
}
}
通过输入以下命令编译此合约:
forge compile
这是一个非常简单的合约,只有两个函数:receive 和 fallback。当没有调用数据时,执行 receive 函数;当发送带有调用数据的函数调用到此合约时,执行回退函数。此外,为了方便调试回退函数的调用,我添加了一个临时的 uint256 值 x。下面我将展示它是如何使用的。
我在这里使用了两个操作码,分别是:calldataload 和 sstore。
✅ calldataload
当你在交易中发送调用数据时,我们将看到如何使用 Javascript,它以 32 字节字的连接形式存在。
你可以使用操作码 calldataload 每次访问 32 字节的调用数据,通过传入开始读取调用数据的偏移值。从我们的 Sandwich 合约中,我们可以看到我们加载了 0x00(=0) 的调用数据。这意味着我们从偏移量 0 读取了一个 32 字节的字。
✅ sstore
接下来,我们将从调用数据中检索到的值存储到我们的变量 x 中。我们的变量 x 是在合约中首先定义的,作为一个 uint256 变量——它是 32 字节。这意味着我们的值 x 存储在存储的第 0 个槽中。这听起来相当复杂,但可以通过 Foundry 进行可视化。尝试运行:
forge inspect src/Sandwich.sol:Sandwich storage-layout
这将输出:
我们可以看到我们的变量/标签 x 在槽 0 中。
所以如果我们回到我们的 sstore 代码,我们正在将“value”的值存储到 x 变量的槽中,即 0。
现在,让我们在同一个 Foundry 项目目录中设置一个节点项目,以开始编写我们的 Javascript 代码。运行:
npm init
npm install ethers@5
注意,我使用的是 ethers 版本 5,而不是版本 6。这是因为网上很多示例仍然使用这个版本,因此这样更容易跟随。
创建一个名为 index.js 的 Javascript 文件:
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();
})();
我们只需关注主函数。在调用回退函数之前,我们获取 x 的值。然后我们发送一个没有要调用的函数名称的交易,而是简单地使用 ethers.utils.solidityPack 编码我们想要发送到合约的值。这将产生:
0x000000000000000000000000000000000000000000000000000000000000000a
然后我们签署原始交易,并检查我们的 x 值是否已更改为 10。
要运行此代码,我将使用 Anvil,这是一个类似于 Ganache 的本地测试网节点。如果你安装了 Foundry,你也会有 Anvil。通过启动另一个终端运行以下命令:
anvil
这将启动你的本地测试网:
这就是运行我们代码的整个设置。
我们将首先部署我们的 Sandwich 合约:
forge create --rpc-url http://127.0.0.1:8545 --private-key 0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80 src/Sandwich.sol:Sandwich
我们得到的输出如下所示:
复制“Deployed to”部分的内容,对我来说是:0xB7f8BC63BbcaD18155201308C8f3540b07f84F5e
将此值粘贴到我们的 JavaScript 代码中。用我们已部署合约的地址替换以下代码片段:
const ADDRESS = '0xB7f8BC63BbcaD18155201308C8f3540b07f84F5e'; // we'll get this address from below
接下来,我们运行我们的 JavaScript 代码:
node index.js
我们成功运行了我们的回退函数:
现在代码设置完成,让我们深入研究原始的回退函数。
Sandwich.sol 的存储布局
首先,让我们创建一个最简化的 Sandwich.sol 代码,如下所示:
// 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 {}
}
这就是 Sandwich.sol 中除了回退函数的所有内容。总共定义了 3 个变量:address、bytes4、bytes4。
让我们检查一下这个合约的存储布局:
forge inspect src/Sandwich.sol:Sandwich storage-layout
我们得到:
{"storage": [], "types": {}}
这很奇怪。然而,由于这些变量都是不可变或常量,因此它们不会被保存到存储中,而是在编译时用这些硬编码值替换代码中使用这些变量的每个地方。
这可以帮助节省 gas 成本,因为存储操作是与智能合约交互时需要注意的一些最昂贵的以太坊操作。
我们了解了合约的存储布局。我们也知道何时可以使用回退函数。我想现在我们准备好填充我们的回退函数的核心逻辑了。
在汇编中使用“require”
让我们从“require”开始。这很简单。确切地说,汇编不支持“require”的等价物,因此我们必须用“revert”来应对。
因为“revert”所做的只是检查条件是否满足,如果条件不成立,我们就会撤销整个交易,撤销所做的任何状态更改。我们必须先进行条件检查,然后显式调用 revert 操作码。
让我们放大回退函数,我们将其填充如下:
fallback() external payable {
address memUser = user;
assembly {
if iszero(eq(caller(), memUser)) {
revert(0, 0) // the same as revert(3, 3)
}
}
}
可惜的是,我们无法使用汇编访问不可变变量,因此我们将 user 的地址设置为 memUser,以便在汇编的作用域外使用。然后,我们开始汇编块。
在这里,我们使用了四个新的操作码,这些操作码相当简单易懂:
- ✅ iszero: 如果给定的值为 0,则返回 true
- ✅ eq: 检查作为函数参数给出的两个参数是否具有相同的值
- ✅ caller: 函数的调用者(相当于 msg.sender)
- ✅ revert: 撤销交易
这段代码既简单又高效,然而,我们可能需要更多关于错误的信息。因此我们可以将代码更改如下:
error NotOwner();
fallback() external payable {
address memUser = user;
assembly {
if iszero(eq(caller(), memUser)) {
let errorPtr := mload(0x40)
mstore(
errorPtr,
0x30cd747100000000000000000000000000000000000000000000000000000000
)
revert(errorPtr, 0x4)
}
}
}
我为一个名为 NotOwner 的错误添加了定义,并在汇编代码块中使用 mload 从内存中加载数据。
接下来,在该内存空间中添加了 NowOwner 的选择器的前 4 个字节(=8 个字符)(30cd7471_2f59d478562d48e2d35de830db72c60a63dd08ae59199eec990b5bc4)。你可以从下面检查这一点:
然后,我再次使用“revert”,但这次返回存储在 errorPtr 中的错误签名。
我们可以使用 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'
}
*/
那么,什么是更好的撤销方法?我认为这完全取决于你的偏好。两者之间的区别本质上在于可读性和 gas 成本:revert(0, 0) 的成本是 21255,而后者的成本是 21282。差别不大。(我会选择更简单的方法。)
使用“calldataload”和“shr”读取 calldata
让我们尝试读取包含多个变量值的打包 calldata。
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'
}
}
要理解用于解析 calldata 的操作码,我们首先应该看看 calldata 是如何传递的。我们将使用 JavaScript 如下:
const payload = ethers.utils.solidityPack(
['address', 'address', 'uint128', 'uint128', 'uint8'],
[
'0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266',
'0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266',
1,
2,
3
]
);
console.log(payload);
为了让我们的生活更轻松,我插入了一些简单处理的十六进制值。结果值看起来像这样:
0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266f39fd6e51aad88f6f4ce6ab8827279cfffb92266000000000000000000000000000000010000000000000000000000000000000203
我会尝试让这个更易读:
- 0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266 (20 字节)
2. f39fd6e51aad88f6f4ce6ab8827279cfffb92266 (20 字节)
3. 00000000000000000000000000000001 (16 字节 = 128 位)
4. 00000000000000000000000000000002 (16 字节 = 128 位)
5. 03 (1 字节 = 8 位)
我们可以看到,连接的变量在长度上有所不同,因为它们的大小不同,然而,我们从上面看到“calldataload”一次只能读取 32 字节的字。
✅ shr
这就是为什么我们需要使用“shr”操作码来位移我们的数据以满足我们的需求。我们将进一步研究这一点。
第一个命令尝试检索“token”数据。这将从第 0 字节索引加载 32 字节的 calldata,相当于 64 个字符。
然而,我们只需要前 20 字节的 calldata,因为地址是 bytes20 类型。我们通过使用“shr”来实现这一点。
将值右移 96 位(=12 字节),我们只剩下所需的数据。
同样的逻辑适用于其他命令:
- pair: 从第 20 字节索引读取 calldata(=0x14,十进制为 20),右移 96 位,
- amountIn: 从第 40 字节索引读取 calldata(=0x28),右移 128 位,
- amountOut: 从第 56 字节索引读取 calldata(=0x38),右移 128 位,
- tokenOutNo: 从第 72 字节索引读取 calldata(=0x48),右移 248 位
在汇编中调用 ERC-20 “transfer” 函数
我们现在将使用汇编调用 ERC-20 的 transfer 函数。我们将快速查看如何调用“transfer”函数:
我们可以看到“transfer”函数接受两个变量:to 和 amount。让我们立即编写我们的汇编代码:
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
我们调用 “mstore(x, y)” 将值 y 存储到内存位置 x 中。
首先,我们将 “transfer” 的 4 字节选择器 0xa9059cbb 存储到内存位置 0x7c(=124 十进制)。在这段数据写入后,我们可以开始将下一个数据存储在 0x7c 之后的 4 字节处,即 0x80(=128 十进制)。
这次,我们将 pair 地址存储在内存位置 0x80。这将占用 32 字节,因此下一个参数可以放入内存位置 0xa0(=160 十进制)。
amountIn 也是如此。从 0xa0 开始,我们将 32 字节的值存储到内存中。
✅ call
你可能会想知道为什么我们必须在执行函数调用之前将函数选择器和参数存储到内存中。这是因为 EVM 的结构是使用内存来处理外部调用的返回、设置外部调用的函数值等任务。
考虑到这一点,使用“call”操作码进行外部函数调用并不太困难。
call(g, a, v, in, insize, out, outsize): 是用于调用地址 a 的合约的操作码,使用的 gas 数量为 g,传递 v wei 作为 msg.value,从 in 开始传递 tx.data 位置,大小为 insize 字节,并将返回的数据存储在从 out 开始的内存位置,大小为 outsize 字节。此外,如果调用成功,此操作码将返回 1,否则返回 0。
我们再次查看我们的代码:
let s1 := call(sub(gas(), 5000), token, 0, 0x7c, 0x44, 0, 0)
✅ gas: 可用于执行的 gas 数量
我们使用 gas() - 5000 调用 token 合约的“transfer”函数,calldata 的结构为:[selector][pair][amountIn]。calldata 将从内存位置 0x7c 以 0x44 字节(= 68 字节 = 4 字节选择器 + 32 字节地址 + 32 字节 uint256)进行检索。此调用不会返回任何值,因此我们为 out, outsize 传递两个 0。
在汇编中调用 Uniswap V2 “swap” 函数
这一部分应该很简单,因为它本质上等同于我们在上一节中调用的“transfer”函数。
在我们编写“swap”调用的汇编之前,让我们去 Uniswap V2 核心合约,看看我们正在调用的函数:
相当复杂,但我们需要知道的是,我们应该将相关的输入代币输入到 pair 合约中,然后我们可以作为结果获得 amountOut 的其他代币。
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)
}
}
}
首先,我们将“swap”函数的选择器 0x022c0d9f 存储到内存位置 0x7c。
接下来,我们检查 tokenOutNo 是否为 0 或 1。如果 tokenOutNo 为 0,这意味着我们从前面的“transfer”部分发送到 pair 的输入代币是代币 1,并且我们希望获得 amountOut 的代币 0 作为回报。
因此,如果 tokenOutNo 为 0,我们将 amountOut 存储到 0x80,并将 0 存储到 0xa0。反之,如果 tokenOutNo 为 1。
接下来,我们将此合约的地址存储到内存位置 0xc0,然后将空字节存储到 0xe0。
最后,我们通过以下方式调用“swap”:
let s2 := call(sub(gas(), 5000), pair, 0, 0x7c, 0xa4, 0, 0)
此函数调用的 calldata 为 164 字节,其总和为:
- selector: 4 字节
- uint256: 32 字节
- uint256: 32 字节
- address: 32 字节
- empty bytes: 64 字节
比较 gas 成本:Solidity 与汇编
最后,我编写了这个名为“swap”的 fallback 函数的 Solidity 版本。以下是我们 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...
}
}
一切保持不变,除了我添加的名为“swap”的额外函数和 IUniswapV2Pair 接口。
为了在主网测试这个并比较两个函数调用的 gas 成本,我将使用 Foundry 对主网进行硬分叉。这个过程让你可以从本地机器使用主网状态,但不下载任何远程数据。对主网进行硬分叉是测试你的函数调用的一个有用方法,因为这将使你能够针对真实的以太坊状态测试你的函数调用。
你基本上可以访问已经在以太坊主网上运行的所有协议,因此你可以运行的测试范围变得无穷无尽,并且比在 Anvil 本地测试网上测试你的合约更为真实。硬分叉不会将所有状态复制到你的本地机器,因此既不会花费很长时间,也不会花费任何费用。
使用 Anvil 进行硬分叉非常简单:
anvil --fork-url <RPC_ENDPOINT_OF_YOUR_CHOICE>
使用你选择的 RPC 端点运行此命令,你就准备好了。
接下来,我们需要将最终的 Sandwich 合约部署到 Anvil 主网硬分叉:
forge create --rpc-url http://127.0.0.1:8545 --constructor-args 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266 --private-key 0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80 src/Sandwich.sol:Sandwich
我得到的响应是:
[⠰] Compiling...
No files changed, compilation skipped
Deployer: 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266
Deployed to: 0xE2b5bDE7e80f89975f7229d78aD9259b2723d11F
Transaction hash: 0x2038f9c7a09037d1ed64d7b93cf7827060ab24ae497c12084bd3a6c086f3df71
复制: 0xE2b5bDE7e80f89975f7229d78aD9259b2723d11F
然后创建一个用于测试的 Javascript 文件:
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());
// 第 4 步:调用 Sandwich 合约的回退函数 //
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();
})();
这是一个较长的脚本,但编写方式易于理解。该脚本包含四个步骤,分别是:
- 包裹 2 个以太币,
- 将 1 个 WETH 转移到 Sandwich 合约,
- 使用 Solidity 版本的“swap”将 0.5 WETH 交换为 USDT,
- 使用 Yul 版本的“swap”将 0.5 WETH 交换为 USDT
最终结果很有趣:
正如我们所看到的,调用 Solidity 版本的 swap 消耗了 100765 gas,而汇编版本消耗了 99373 gas。 gas 成本有所改善。
接下来是什么?
这篇文章较长,涉及在 MEV 交易中使用汇编。我们看到使用汇编可以使我们的合约更具 gas 效率。
在接下来的文章中,我将:
- 使用 Python、Javascript、Golang 和 Rust 构建一个简单的 MEV 机器人,然后尝试同时运行它们,看看语言差异是否会对性能提升产生影响。
- 使用 REVM 构建一个简单的交易模拟器。这可以帮助理解 Foundry 的底层工作原理,并帮助构建一个高度优化的模拟引擎供我们的 MEV 机器人使用。
- 尝试理解由 MevAlphaLeak 编写的 ApeBot
- 构建一个简单的 CEX-DEX 套利机器人,以反向执行影响价格的交易。
我目前正在同时进行这四个项目,不知道哪个会先完成。我知道我必须专注于一个项目,但我觉得我天生就适合多任务处理……https://t.me/gtokentool。