指南: 如何在 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 和操作码时,我发现了一些很好的资源来帮助我理解基础知识,但实际上没有多少资源给出了如何在真实项目中使用汇编的示例。

目录:

  1. 使用的操作码
  2. Sandwich.sol 的存储布局
  3. 在汇编中使用“require”
  4. 使用“calldataload”和“shr”读取 calldata
  5. 在汇编中调用 ERC-20 “transfer” 函数
  6. 在汇编中调用 Uniswap V2 “swap” 函数
  7. 比较 gas 成本:Solidity vs. Assembly
  8. 接下来是什么?

使用的操作码

在回退函数中使用了几个操作码。

  • 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

我会尝试让这个更易读:

  1. 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();  
})();  

这是一个较长的脚本,但编写方式易于理解。该脚本包含四个步骤,分别是:

  1. 包裹 2 个以太币,
  2. 将 1 个 WETH 转移到 Sandwich 合约,
  3. 使用 Solidity 版本的“swap”将 0.5 WETH 交换为 USDT,
  4. 使用 Yul 版本的“swap”将 0.5 WETH 交换为 USDT

最终结果很有趣:

正如我们所看到的,调用 Solidity 版本的 swap 消耗了 100765 gas,而汇编版本消耗了 99373 gas。 gas 成本有所改善。

接下来是什么?

这篇文章较长,涉及在 MEV 交易中使用汇编。我们看到使用汇编可以使我们的合约更具 gas 效率。

在接下来的文章中,我将:

  1. 使用 Python、Javascript、Golang 和 Rust 构建一个简单的 MEV 机器人,然后尝试同时运行它们,看看语言差异是否会对性能提升产生影响。
  2. 使用 REVM 构建一个简单的交易模拟器。这可以帮助理解 Foundry 的底层工作原理,并帮助构建一个高度优化的模拟引擎供我们的 MEV 机器人使用。
  3. 尝试理解由 MevAlphaLeak 编写的 ApeBot 
  4. 构建一个简单的 CEX-DEX 套利机器人,以反向执行影响价格的交易。

我目前正在同时进行这四个项目,不知道哪个会先完成。我知道我必须专注于一个项目,但我觉得我天生就适合多任务处理……https://t.me/gtokentool。

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:/a/940372.html

如若内容造成侵权/违法违规/事实不符,请联系我们进行投诉反馈qq邮箱809451989@qq.com,一经查实,立即删除!

相关文章

每日十题八股-2024年12月19日

1.Bean注入和xml注入最终得到了相同的效果&#xff0c;它们在底层是怎样做的&#xff1f; 2.Spring给我们提供了很多扩展点&#xff0c;这些有了解吗&#xff1f; 3.MVC分层介绍一下&#xff1f; 4.了解SpringMVC的处理流程吗&#xff1f; 5.Handlermapping 和 handleradapter有…

kkfileview代理配置,Vue对接kkfileview实现图片word、excel、pdf预览

kkfileview部署 官网&#xff1a;https://kkfileview.keking.cn/zh-cn/docs/production.html 这个是官网部署网址&#xff0c;这里推荐大家使用docker镜像部署&#xff0c;因为我是直接找运维部署的&#xff0c;所以这里我就不多说明了&#xff0c;主要说下nginx代理配置&am…

SQL语句整理五-StarRocks

文章目录 查看版本号&#xff1a;SPLIT&#xff1a;insert 和 update 结合 select&#xff1a;报错&#xff1a;1064 - StarRocks planner use long time 3000 ms in memo phase&#xff1a;字段增删改&#xff1a; 查看版本号&#xff1a; select current_version(); current…

使用Turtle库实现,鼠标左键绘制路径,用鼠标右键结束绘制,小海龟并沿路径移动

使用Turtle库实现&#xff0c;鼠标左键绘制路径&#xff0c;用鼠标右键结束绘制&#xff0c;小海龟并沿路径移动 Turtle库是Python标准库的一部分&#xff0c;它提供了一种基于命令的图形绘制方式。Turtle模块通过一个“海龟”&#xff08;Turtle&#xff09;对象在屏幕上移动…

centos-stream9系统安装docker

如果之前安装过docker需要删除之前的。 sudo dnf -y remove docker docker-client docker-client-latest docker-common docker-latest docker-latest-logrotate docker-logrotate docker-engine 安装yum-utils工具&#xff1a; dnf -y install yum-utils dnf-plugin…

Spark优化----Spark 数据倾斜

目录 数据倾斜的表现&#xff1a; 定位数据倾斜问题&#xff1a; 解决方案一&#xff1a;聚合原数据 避免 shuffle 过程 缩小 key 粒度&#xff08;增大数据倾斜可能性&#xff0c;降低每个 task 的数据量&#xff09; 增大 key 粒度&#xff08;减小数据倾斜可能性&#xff0c…

视频点播系统|Java|SSM|VUE| 前后端分离

【技术栈】 1⃣️&#xff1a;架构: B/S、MVC 2⃣️&#xff1a;系统环境&#xff1a;Windowsh/Mac 3⃣️&#xff1a;开发环境&#xff1a;IDEA、JDK1.8、Maven、Mysql5.7 4⃣️&#xff1a;技术栈&#xff1a;Java、Mysql、SSM、Mybatis-Plus、VUE、jquery,html 5⃣️数据库可…

从想法到实践:Excel 转 PPT 应用的诞生之旅

2024 年 11 月&#xff0c;我着手开发了一款exe应用&#xff0c;其主要功能是读取 Excel 文件中的数据&#xff0c;并生成 PPT 文件。 这款应用看似简单&#xff0c;却给我的商业认知带来了深刻的启发。此前&#xff0c;我与一位老师合作&#xff0c;为其处理 Excel 转 PPT 的…

LabVIEW深海气密采水器测控系统

LabVIEW的深海气密采水器测控系统通过高性价比的硬件选择与自主开发的软件&#xff0c;实现了高精度的温度、盐度和深度测量&#xff0c;并在实际海上试验中得到了有效验证。 项目背景 深海气密采水器是进行海底科学研究的关键工具&#xff0c;用LabVIEW开发了一套测控系统&am…

Fastdfs V6.12.1集群部署(arm/x86均可用)

文章目录 一、 Fastdfs 介绍二、部署 信息三、步骤tracker/storage 机器的 compose 内容storage 机器的 composetracker 与 storage 启动目录层级与配置文件测试测试集群扩容与缩减注意事项 一、 Fastdfs 介绍 FastDFS 是一款高性能的分布式文件系统&#xff0c;特别适合用于存…

使用Miniforge构建数据科学环境

一、背景 最近&#xff0c;有不少公司因为员工在工作电脑上安装和使用Anaconda和Miniconda存在商业风险而禁用这两个软件&#xff0c;员工需要找到一个替代方案&#xff0c;Miniforge成为首选&#xff08;对习惯使用conda管理python环境的同学&#xff09; 但实际安装过程中&…

MacOS下PostIn安装配置指南

PostIn是一款开源免费的接口管理工具&#xff0c; 下面介绍私有部署版本的MacOS下安装与配置。私有部署版本更适合有严格数据安全要求的企业&#xff0c;实现对数据和系统的完全控制。 &#xfeff; &#xfeff; 1、MacOS服务端安装 Mac安装包下载地址&#xff1a;下载Mac安…

P6打卡—Pytorch实现人脸识别

&#x1f368; 本文为&#x1f517;365天深度学习训练营中的学习记录博客&#x1f356; 原作者&#xff1a;K同学啊 1.检查GPU import torch import torch.nn as nn import matplotlib.pyplot as plt import torchvisiondevicetorch.device("cuda" if torch.cuda.is_…

Electronjs+Vue如何开发PC桌面客户端(Windows,Mac,Linux)

electronjs官网 https://www.electronjs.org/zh/ Electron开发PC桌面客户端的技术选型非常适合已经有web前端开发人员的团队。能够很丝滑的过渡。 Electron是什么&#xff1f; Electron是一个使用 JavaScript、HTML 和 CSS 构建桌面应用程序的框架。 嵌入 Chromium 和 Node.…

内旋风铣削知识再学习

最近被有不少小伙伴们问到蜗杆加工的一种方式——内旋风铣削加工。关于旋风铣之前出过一篇《什么是旋风铣&#xff1f;》&#xff0c;简要介绍了旋风铣&#xff08;Whilring&#xff09;的一些基本内容。本期再重新仔细聊一聊内旋风这种加工方式&#xff0c;可加工的零件种类&a…

centos7下docker 容器实现redis主从同步

1.下载redis 镜像 docker pull bitnami/redis2. 文件夹授权 此文件夹是 你自己映射到宿主机上的挂载目录 chmod 777 /app/rd13.创建docker网络 docker network create mynet4.运行docker 镜像 安装redis的master -e 是设置环境变量值 docker run -d -p 6379:6379 \ -v /a…

基于Spring Boot的远程教育网站

一、系统背景与意义 随着互联网技术的飞速发展和普及&#xff0c;远程教育已成为现代教育体系中的重要组成部分。它打破了时间和空间的限制&#xff0c;让学习者可以随时随地进行学习。基于Spring Boot的远程教育网站正是为了满足这一需求而设计的&#xff0c;它利用互联网技术…

cf补题日记4

进度&#xff1a;6/40 我觉得我的思维还是太差了&#xff0c;多练思维题吧&#xff01;&#xff01;&#xff01;&#xff01;&#xff08;燃起来 简直是思维题b题专题了&#xff0c;现在连b都做不出了吗&#xff08;悲 原题1&#xff1a; Cats are attracted to pspspsps, …

WPF Binding 绑定

绑定是 wpf 开发中的精髓&#xff0c;有绑定才有所谓的数据驱动。 1 . 背景 目前 wpf 界面可视化的控件&#xff0c;继承关系如下&#xff0c; 控件的数据绑定&#xff0c;基本上都要借助于 FrameworkElement 的 DataContext 属性。 只有先设置了控件的 DataContext 属性&…

datasets笔记:两种数据集对象

Datasets 提供两种数据集对象&#xff1a;Dataset 和 ✨ IterableDataset ✨。 Dataset 提供快速随机访问数据集中的行&#xff0c;并支持内存映射&#xff0c;因此即使加载大型数据集也只需较少的内存。IterableDataset 适用于超大数据集&#xff0c;甚至无法完全下载到磁盘或…