REVM/Anvil/Alloy 模拟 MEV 套利

在这篇教程中,我将描述如何使用Anvil及其底层对应物REVM来检测UniswapV3 MEV套利机会。

REVM/Anvil/Alloy 模拟 MEV 套利
一键发币: SUI | SOL | BNB | ETH | BASE | ARB | OP | POLYGON | AVAX | FTM | OK

EVM模拟引擎是任何竞争性MEV策略的核心组件。在这篇教程中,我将描述如何使用Anvil及其底层对应物REVM来检测UniswapV3 MEV套利机会。我们将使用Alloy(ethers-rs的后继者)实现一个可行的概念证明。我还将讨论提高基于REVM的模拟性能和可扩展性的技术。

1、计算Uniswap V3套利利润

我们不会在这个教程中构建完整的MEV机器人。相反,我们将专注于一个模拟引擎,用于检测以太坊主网上Uniswap V3池之间的套利机会。

Uniswap V2的汇率计算相对简单,可以在链下完成(即无需使用eth_call RPC查询智能合约)。相比之下,Uniswap V3要复杂得多,其核心部分用低级Solidity Yul汇编编写。整个eBooks数小时的YouTube系列都致力于解释其内部工作原理。

解构Defi协议本身就是一个有趣的挑战。但为了优化你的MEV机器人的盈利能力,你可能需要整合数十个协议。因此,为所有这些协议进行逆向工程以进行链下计算可能对大多数MEV搜索者来说不可扩展。

基准测试方法说明:在最佳情况下,通过运行专用本地节点可以获得最好的EVM模拟性能。你也不必担心提供程序的速率限制和网络延迟问题。但实际上,不可能为每个你想要测试MEV策略的晦涩EVM链启动完整节点。这就是为什么在后续的基准测试中,我会包括在相同VPS上运行的本地完整Geth节点以及第三方(Alchemy成长计划,即无速率限制)RPC端点的测量结果。

使用第三方端点检查性能将突出显示从网络层修复中获得的改进。我们还将衡量不同模拟策略触发的RPC调用数量。GitHub仓库中的示例使用公开可用的Infura RPC端点。每次执行的测量值通常会有所不同,大约在10%-20%之间,但所描述的改进在多次执行中是一致的。

如果你想用本地完整Geth节点测试示例,可以查看我的教程在AWS上运行以太坊节点

2、eth_call的局限性

我们将计算两个Uniswap V3 ETH/USDC对之间的套利机会。Uniswap V3为相同的ERC20代币对提供了多个池,使用不同的费用。为了计算套利,我们需要知道特定WETH金额可以兑换多少USDC。稍后,我们将对另一个池上的USDC金额进行相同的计算,看看是否有盈利空间。

Uniswap V3提供了QuoterV2合约,该合约允许计算两种ERC20代币之间的汇率。这正是我们需要的来检测套利机会。让我们来看看它的实际应用。

所有代码示例都可以在Github仓库中找到。在本文最后一次更新时,我使用的是Rust编译器版本1.81.0。为了简洁起见,博客文章中省略了一些辅助函数的实现和导入。

首先,我们实现Alloy助手函数来编码和解码quoteExactInputSingle函数数据。

来源:src/source/abi.rs

sol! {
    struct QuoteExactInputSingleParams {
        address tokenIn;
        address tokenOut;
        uint256 amountIn;
        uint24 fee;
        uint160 sqrtPriceLimitX96;
    }

    function quoteExactInputSingle(QuoteExactInputSingleParams memory params)
    public
    override
    returns (
        uint256 amountOut,
        uint160 sqrtPriceX96After,
        uint32 initializedTicksCrossed,
        uint256 gasEstimate
    );

}

pub fn quote_calldata(token_in: Address, token_out: Address, amount_in: U256, fee: u32) -> Bytes {
    let zero_for_one = token_in < token_out;

    let sqrt_price_limit_x96: U160 = if zero_for_one {
        "4295128749".parse().unwrap()
    } else {
        "1461446703485210103287273052203988822378723970341"
            .parse()
            .unwrap()
    };

    let params = QuoteExactInputSingleParams {
        tokenIn: token_in,
        tokenOut: token_out,
        amountIn: amount_in,
        fee: U24::from(fee),
        sqrtPriceLimitX96: sqrt_price_limit_x96,
    };

    Bytes::from(quoteExactInputSingleCall { params }.abi_encode())
}

pub fn decode_quote_response(response: Bytes) -> Result<u128> {
    let (amount_out, _, _, _) = <(u128, u128, u32, u128)>::abi_decode(&response, false)?;
    Ok(amount_out)
}

Alloy特性支持这种sol!宏,可以直接在Rust中嵌入Solidity代码。不再需要在Rust项目中转储不可读的JSON ABI blob了!如你所见,它还可以处理复杂的结构体类型。数据编码和解码是编译时安全的。

quote_calldata方法返回用于我们的报价函数的编码字节数据。sqrt_price_limit_x96代表我们愿意支付的价格限制。然而,由于我们的MEV策略假设我们将在新区块中成为第一个与池交互的人,我们使用MIN/MAX值(取决于我们交换的代币)。

decode_quote_response解码quoteExactInputSingle方法的输出,并返回amount_out,即我们感兴趣的值。

3、使用eth_call获取Uniswap V3报价

现在,让我们实际进行eth_call调用。这是一个工作示例:

来源 src/eth_call_one.rs

#[tokio::main]
async fn main() -> Result<()> {
    env_logger::init();

    let provider = ProviderBuilder::new().on_http(std::env::var("ETH_RPC_URL").unwrap().parse()?);
    let provider = Arc::new(provider);

    let base_fee = provider.get_gas_price().await?;

    let volume = one_ether().div(U256::from(10));
    let calldata = quote_calldata(weth_addr(), usdc_addr(), volume, 3000);

    let tx = build_tx(official_quoter_addr(), me(), calldata, base_fee);
    let start = measure_start("eth_call_one");
    let call = provider.call(&tx).await?;

    let amount_out = decode_quote_response(call)?;
    println!("{} WETH -> USDC {}", volume, amount_out);

    measure_end(start);

    Ok(())
}

我们从RPC连接URL实例化Alloy HTTP提供程序。然后,我们获取当前的Gas价格并将其传递给build_tx辅助函数,同时传递quote_calldata方法生成的calldata。之后,我们使用静态eth_call通过provider.call方法,并使用decode_quote_response进行解码。

你可以通过输入以下命令来运行它:

cargo run --bin eth_call_one --release

# 100000000000000000 WETH -> USDC 294930306
# Elapsed: 9.59ms for 'eth_call_one' (F)
# Elapsed: 52.62ms for 'eth_call_one' (A)

我将在测量结果中使用(F)后缀表示使用本地全节点的结果,使用(A)后缀表示使用第三方Alchemy节点的结果。

根据输出结果,看起来我们可以用0.1 ETH购买约$295。单次eth_call在本地全节点上大约需要10ms,在第三方提供程序上则慢约5倍。

现在像这样运行示例:

RUST_LOG=debug cargo run --bin eth_call --release

由于启用了日志记录,你应该看到类似以下的输出:

# DEBUG [alloy_rpc_client::call] sending request method=eth_call id=1

这意味着我们的脚本触发了一个单独的RPC eth_call请求。这在后面会很有帮助!

4、发送多个eth_call请求

为了计算最优套利,你通常需要针对每种代币对测试不同的交易量。让我们看看一个工作示例并测量其性能:

来源 src/eth_call.rs

#[tokio::main]
async fn main() -> Result<()> {
    env_logger::init();

    let provider = ProviderBuilder::new().on_http(std::env::var("ETH_RPC_URL").unwrap().parse()?);
    let provider = Arc::new(provider);

    let base_fee = provider.get_gas_price().await?;

    let volumes = volumes(U256::ZERO, one_ether().div(U256::from(10)), 100);

    let start = measure_start("eth_call");
    for (index, volume) in volumes.into_iter().enumerate() {
        let calldata = quote_calldata(weth_addr(), usdc_addr(), volume, 3000);
        let tx = build_tx(official_quoter_addr(), me(), calldata, base_fee);
        let response = provider.call(&tx).await?;
        let amount_out = decode_quote_response(response)?;
        if index % 20 == 0 {
            println!("{} WETH -> USDC {}", volume, amount_out);
        }
    }

    measure_end(start);

    Ok(())
}

在这个示例中,我们使用volumes辅助函数生成100个均匀分布的交易量,范围从0到0.1 ETH。之后,我们遍历所有这些交易量以获取报价。让我们看看我们的基准测试结果:

cargo run --bin eth_call --release

# Elapsed: 89.45ms for 'eth_call' (F)
# Elapsed: 4.39s for 'eth_call' (A)

你可以看到,对于多次RPC调用,网络开销变得显著。使用第三方提供程序现在比之前快约50倍。我在美国地理位置的VPS服务器上运行,即与Alchemy节点相同的位置。如果调用必须跨越海洋,那么减速幅度会更大。

如果我们用RUST_LOG=debug运行此示例,你应该看到如下输出:

# DEBUG [alloy_rpc_client::call] sending request method=eth_call id=100

这意味着我们触发了100个RPC调用,每个交易量一个。有几种方法可以通过并行运行请求来加速此示例。但这并不能改变这样一个事实,即在这种方式下我们正在向节点发送大量RPC请求。一些第三方提供程序可能会应用速率限制,因此你需要调整你的机器人以利用这种方法。此外,持续向本地节点发送大量RPC请求可能会降低其性能并使其落后于区块链头。

让我们看看如何通过使用Anvil来改进这个示例。

5、如何在Rust中使用Anvil?

Anvil是由The Ripped Jesus赠予我们的工具之一。它可以用于分叉EVM链并在本地运行它们。让我们看看如何直接在Rust中使用它来优化之前的示例。

来源 src/anvil.rs

#[tokio::main]
async fn main() -> Result<()> {
    env_logger::init();

    let rpc_url: Url = std::env::var("ETH_RPC_URL").unwrap().parse()?;

    let provider = ProviderBuilder::new().on_http(rpc_url.clone());
    let provider = Arc::new(provider);

    let base_fee = provider.get_gas_price().await?;

    let fork_block = provider.get_block_number().await?;
    let anvil = Anvil::new()
        .fork(rpc_url)
        .fork_block_number(fork_block)
        .block_time(1_u64)
        .spawn();

    let anvil_provider = ProviderBuilder::new().on_http(anvil.endpoint().parse().unwrap());
    let anvil_provider = Arc::new(anvil_provider);

    let volumes = volumes(U256::ZERO, one_ether().div(U256::from(10)), 100);

    let start = measure_start("anvil_first");
    let first_volume = volumes[0];
    let calldata = quote_calldata(weth_addr(), usdc_addr(), first_volume, 3000);
    let tx = build_tx(official_quoter_addr(), me(), calldata, base_fee);
    let response = anvil_provider.call(&tx).await?;
    let amount_out = decode_quote_response(response)?;
    println!("{} WETH -> USDC {}", first_volume, amount_out);
    measure_end(start);

    let start = measure_start("anvil");
    for (index, volume) in volumes.into_iter().enumerate() {
        let calldata = quote_calldata(weth_addr(), usdc_addr(), volume, 3000);
        let tx = build_tx(official_quoter_addr(), me(), calldata, base_fee);
        let response = anvil_provider.call(&tx).await?;
        let amount_out = decode_quote_response(response)?;
        if index % 20 == 0 {
            println!("{} WETH -> USDC {}", volume, amount_out);
        }
    }

    measure_end(start);
    drop(anvil);

    Ok(())
}

这次设置更复杂,因为我们必须实例化provideranvil_provideranvil_provider将直接与运行在本地端口上的Anvil通信。最好在anvil实例上调用drop以终止进程。如果我们在该方法中做更多的工作,它会一直存在,占用服务器资源。

其余代码类似于之前的示例,但我们分别测量第一次调用和剩余99次的性能。你能猜到为什么吗?让我们看看基准测试的结果:

# Elapsed: 11.22ms for 'anvil_first' (F)
# Elapsed: 759.82ms for 'anvil_first' (A)
# Elapsed: 109.44ms for 'anvil' (F)/(A)

对于Alchemy,第一次计算花了大约700ms,但剩下的99次只花了100ms。本地节点在第一次计算时快了约70倍,但在剩下的99次中两者表现相似。这是怎么回事?

Anvil的工作原理是隐式地按需获取必要数据。这意味着第一次call必须与我们的源RPC节点通信,并发出多个请求(eth_getCodeeth_getStorageAteth_getBalance)以填充模拟所需的数据。这就是为什么我们将volumes按降序排列。最大的Uniswap V3交易需要最多的数据(所谓的“ticks”)。

要计算Anvil触发的请求数量,必须使用RUST_LOG=trace。对于我们的示例,我发现有36个sending request实例。此外,仍然有100个sending request method=eth_call,就像之前的示例一样。但这些请求针对的是我们的本地Anvil进程,而不是向我们的原始全节点发送请求。

通过利用Anvil,我们将外部RPC调用的数量从100减少到36。因此,我们提高了Alchemy的整体执行时间,从原来的**~5秒缩短到~1秒**(快了5倍),但对于本地节点,性能现在变差了约30%(从**~90ms变为~120ms**)。

让我们看看如何通过更深入地使用REVM来进一步优化这个例子。

6、如何使用REVM进行MEV模拟?

REVM是Ethereum执行层的Rust实现。Foundry(包括Anvil)和RETH都在其底层使用了它。让我们看看如何用REVM重写之前的示例。

来源 src/source/helpers.rs

pub type AlloyCacheDB = CacheDB<AlloyDB<Http<Client>, Ethereum, Arc<RootProvider<Http<Client>>>>>;

pub fn revm_call(
    from: Address,
    to: Address,
    calldata: Bytes,
    cache_db: &mut AlloyCacheDB,
) -> Result<Bytes> {
    let mut evm = Evm::builder()
        .with_db(cache_db)
        .modify_tx_env(|tx| {
            tx.caller = from;
            tx.transact_to = TransactTo::Call(to);
            tx.data = calldata;
            tx.value = U256::from(0);
        })
        .build();

    let ref_tx = evm.transact().unwrap();
    let result = ref_tx.result;

    let value = match result {
        ExecutionResult::Success {
            output: Output::Call(value),
            ..
        } => value,
        result => {
            return Err(anyhow!("execution failed: {result:?}"));
        }
    };

    Ok(value)
}

pub fn init_cache_db(provider: Arc<RootProvider<Http<Client>>>) -> AlloyCacheDB {
    CacheDB::new(AlloyDB::new(provider, Default::default()))
}

我们首先实现revm_call助手方法。这是执行REVM事务的标准模板。我们基于提供程序fromto地址和calldata执行ETH调用。

evm.transact().unwrap();执行实际的事务而不将更改持久化到数据库中(类似于eth_call)。如果你想要测试一系列事务,则必须使用evm.transact_commit().unwrap();

cache_db具有冗长的类型注释,表示REVM数据存储和检索的对象。我们使用init_cache_db助手方法初始化它。它接受标准的RPC提供程序。这种设置允许它隐式获取模拟所需的必要数据,就像Anvil一样。

让我们现在实现其余的示例:

来源 src/revm.rs

#[tokio::main]
async fn main() -> Result<()> {
    env_logger::init();

    let provider = ProviderBuilder::new().on_http(std::env::var("ETH_RPC_URL").unwrap().parse()?);
    let provider = Arc::new(provider);

    let volumes = volumes(U256::ZERO, one_ether().div(U256::from(10)), 100);

    let mut cache_db = init_cache_db(provider.clone());

    let start = measure_start("revm_first");
    let first_volume = volumes[0];
    let calldata = quote_calldata(weth_addr(), usdc_addr(), first_volume, 3000);
    let response = revm_call(me(), official_quoter_addr(), calldata, &mut cache_db)?;
    let amount_out = decode_quote_response(response)?;
    println!("{} WETH -> USDC {}", first_volume, amount_out);
    measure_end(start);

    let start = measure_start("revm");
    for (index, volume) in volumes.into_iter().enumerate() {
        let calldata = quote_calldata(weth_addr(), usdc_addr(), volume, 3000);
        let response = revm_call(me(), official_quoter_addr(), calldata, &mut cache_db)?;

        let amount_out = decode_quote_response(response)?;
        if index % 20 == 0 {
            println!("{} WETH -> USDC {}", volume, amount_out);
        }
    }

    measure_end(start);

    Ok(())
}

这应该已经很熟悉了。我们使用之前定义的revm_call方法代替provider.call。让我们看看基准测试结果:

cargo run --bin revm --release

# Elapsed: 10.76ms for 'revm_first' # (F)
# Elapsed: 935.84ms for 'revm_first' (A)
# Elapsed: 70.11ms for 'revm' (F)/(A)

结果与Anvil类似。第一次Alchemy调用花费了大约一秒钟来获取必要的数据,而随后的99次迭代快了约30%。这些结果非常合理。Anvil在底层使用REVM。因此,通过消除跨进程网络开销,我们可以节省这30%的时间。

如果启用RUST_LOG=debug,我们可以看到以下输出:

# ... 
# DEBUG [alloy_rpc_client::call] sending request method=eth_getBalance id=31
# DEBUG [alloy_rpc_client::call] sending request method=eth_getCode id=32
# DEBUG [alloy_rpc_client::call] sending request method=eth_getStorageAt id=33

即总共33个RPC请求。这也是合理的,因为无论使用什么工具进行模拟,我们都必须从原始节点获取相同的数据。

这就结束了吗?我们已经达到了REVM性能和EVM模拟可扩展性的极限了吗?

敬请期待。在下一节中,我们将更深入地探讨REVM和EVM的内部机制。

7、如何提高REVM性能?

缓存所有的字节码

在RPC日志输出中,我们可以目前统计到八个eth_getCode调用。eth_getCode下载目标智能合约的字节码。字节码是在智能合约部署期间定义的。即使是看似“可变”的智能合约,如UDSC,也使用代理模式,通过更改实现槽位来委托调用到新地址。字节码永远不会改变。

除了eth_getCode,我还看到日志输出中有八个eth_getBalanceeth_getTransactionCount实例。ETH余额和非ces与我们的模拟无关。但REVM默认使用AlloyDBbasic_ref方法获取它们。

这里有一个助手方法,用于提高REVM智能合约初始化的性能(如果非ces和ETH余额无关紧要):

src/source/helpers.rs

pub async fn init_account(
    address: Address,
    cache_db: &mut AlloyCacheDB,
    provider: Arc<RootProvider<Http<Client>>>,
) -> Result<()> {
    let cache_key = format!("bytecode-{:?}", address);
    let bytecode = match cacache::read(&cache_dir(), cache_key.clone()).await {
        Ok(bytecode) => {
            let bytecode = Bytes::from(bytecode);
            Bytecode::new_raw(bytecode)
        }
        Err(_e) => {
            let bytecode = provider.get_code_at(address).await?;
            let bytecode_result = Bytecode::new_raw(bytecode.clone());
            let bytecode = bytecode.to_vec();
            cacache::write(&cache_dir(), cache_key, bytecode.clone()).await?;
            bytecode_result
        }
    };
    let code_hash = bytecode.hash_slow();
    let acc_info = AccountInfo {
        balance: U256::ZERO,
        nonce: 0_u64,
        code: Some(bytecode),
        code_hash,
    };
    cache_db.insert_account_info(address, acc_info);
    Ok(())
}

我们使用cacache-rs crate缓存所有受影响地址的字节码。区块链不变性的无可争议优势是你不需要担心缓存驱逐策略。Cacache使用文件存储,所以你可以积极缓存所有必要的数据。

我们通过将余额和nonce设置为零来模拟AccountInfo。正如我们之前讨论的,这种情况不影响结果。但是,如果你开始使用缓存和模拟,请始终确保将模拟的模拟结果与标准eth_call的结果进行比较。

这种方法进一步减少了RPC调用的数量,但我们还可以做得更好。

在我完成所有修复后,我会提供一个带有缓存的应用程序的完整工作示例。

模拟智能合约字节码

下一个优化需要我们深入研究QuoterV2合约的源代码。简而言之,它调用UniswapV3Pool合约swap方法,匹配提供的代币地址和费用。swap做一些数学运算来计算出金额。一个有趣的收获是swap只调用选定的ERC20代币的balanceOftransferFrom方法。这意味着我们通常不需要原版的ERC20实现就能让它工作。这影响更大,因为我们使用的是UDSC代币,它会获取两个字节码(代理和实现)以及实现槽位。

请注意,模拟ERC20字节码将破坏使用自定义转账费用机制的代币的模拟。它还会使你的Gas估算不够准确。但,提高性能通常是权衡的问题,我想展示可用的不同技术。

这里有一个助手方法,用于实例化带有模拟字节码的智能合约:

src/source/helpers.rs

pub async fn init_account_with_bytecode(
    address: Address,
    bytecode: Bytecode,
    cache_db: &mut AlloyCacheDB,
) -> Result<()> {
    let code_hash = bytecode.hash_slow();
    let acc_info = AccountInfo {
        balance: U256::ZERO,
        nonce: 0_u64,
        code: Some(bytecode),
        code_hash,
    };

    cache_db.insert_account_info(address, acc_info);
    Ok(())
}

我们可以通过模拟ERC20代币的字节码余额进一步提高性能。为什么我们可以模拟余额?以下是UniswapV3Pool#swap实现中相关的Solidity代码:

    if (zeroForOne) {
        if (amount1 < 0) TransferHelper.safeTransfer(token1, recipient, uint256(-amount1));

        uint256 balance0Before = balance0();
        IUniswapV3SwapCallback(msg.sender).uniswapV3SwapCallback(amount0, amount1, data);
        require(balance0Before.add(uint256(amount0)) <= balance0(), 'IIA');
    } else {
        if (amount0 < 0) TransferHelper.safeTransfer(token0, recipient, uint256(-amount0));

        uint256 balance1Before = balance1();
        IUniswapV3SwapCallback(msg.sender).uniswapV3SwapCallback(amount0, amount1, data);
        require(balance1Before.add(uint256(amount1)) <= balance1(), 'IIA');
    }

它检查转移前后的余额变化。这意味着绝对值不相关。通过模拟池的余额,我们可以减少两个eth_getStorageAt调用。

这是如何模拟映射存储槽的方法:

来源 src/source/helpers.rs

pub async fn insert_mapping_storage_slot(
    contract: Address,
    slot: U256,
    slot_address: Address,
    value: U256,
    cache_db: &mut AlloyCacheDB,
) -> Result<()> {
    let hashed_balance_slot = keccak256((slot_address, slot).abi_encode());

    cache_db.insert_account_storage(contract, hashed_balance_slot.into(), value)?;
    Ok(())
}

它实现了Solidity约定,其中映射键存储槽是keccak256的映射存储槽和键值。

当你开始调整存储槽时,slither就成为一个不可或缺的工具。你可以使用以下命令来显示目标智能合约的所有存储槽:

slither-read-storage 0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2

# INFO:Slither-read-storage:
# Name: name
# Type: string
# Slot: 0

# INFO:Slither-read-storage:
# Contract 'WETH9'
# WETH9.symbol with type string is located at slot: 1

# INFO:Slither-read-storage:
# Name: symbol
# Type: string
# Slot: 1
#...

我推荐asdf用于轻松切换本地Solidity版本。你可以阅读我的其他教程了解更多关于它的信息。

另一个有用的技巧是调查REVM数据库的内容。你可以这样做:

dbg!(&cache_db.accounts[&weth_addr()]);

// [src/revm.rs:32:5] &cache_db.accounts[&weth_addr()] = DbAccount {
//     info: AccountInfo {
//         balance: 0x0000000000000000000000000000000000000000000287660c4c202d84e43435_U256,
//         nonce: 1,
//         code_hash: 0xd0a06b12ac47863b5c7be4185c2deaad1c61557033f56c7d4ea74429cbb25e23,
//         code: Some(
//             LegacyRaw(
// 0x6060604052600436106100af576...
// ...
//            ),
//     ),
// },
// account_state: None,
// storage: {
//     0xfc581e2e1d759407b26acc35e3d0231aeae791f35404c37eeed17c8cdf81bcfd_U256: 0x00000000000000000000000000000000000000000000042a2f89c0b26f60c419_U256,
// },

你可以快速查看字节码和存储槽的值,这对调试会话非常有价值。

测量性能改进

这里是应用上述修复后的完整工作示例:

src/revm_cached.rs

#[tokio::main]
async fn main() -> Result<()> {
    env_logger::init();

    let provider = ProviderBuilder::new().on_http(std::env::var("ETH_RPC_URL").unwrap().parse()?);

    let provider = Arc::new(provider);

    let volumes = volumes(U256::ZERO, one_ether().div(U256::from(10)), 100);

    let mut cache_db = init_cache_db(provider.clone());

    init_account(official_quoter_addr(), &mut cache_db, provider.clone()).await?;
    init_account(pool_3000_addr(), &mut cache_db, provider.clone()).await?;
    let mocked_erc20 = include_str!("bytecode/generic_erc20.hex");
    let mocked_erc20 = Bytes::from_str(mocked_erc20).unwrap();
    let mocked_erc20 = Bytecode::new_raw(mocked_erc20);

    init_account_with_bytecode(weth_addr(), mocked_erc20.clone(), &mut cache_db).await?;
    init_account_with_bytecode(usdc_addr(), mocked_erc20.clone(), &mut cache_db).await?;
    let mocked_balance = U256::MAX.div(U256::from(2));
    insert_mapping_storage_slot(
        weth_addr(),
        U256::ZERO,
        pool_3000_addr(),
        mocked_balance,
        &mut cache_db,
    )
    .await?;
    insert_mapping_storage_slot(
        usdc_addr(),
        U256::ZERO,
        pool_3000_addr(),
        mocked_balance,
        &mut cache_db,
    )
    .await?;

    let start = measure_start("revm_cached_first");
    let first_volume = volumes[0];
    let calldata = quote_calldata(weth_addr(), usdc_addr(), first_volume, 3000);
    let response = revm_call(me(), official_quoter_addr(), calldata, &mut cache_db)?;
    let amount_out = decode_quote_response(response)?;
    println!("{} WETH -> USDC {}", first_volume, amount_out);
    measure_end(start);

    let start = measure_start("revm_cached");
    for (index, volume) in volumes.into_iter().enumerate() {
        let calldata = quote_calldata(weth_addr(), usdc_addr(), volume, 3000);
        let response = revm_call(me(), official_quoter_addr(), calldata, &mut cache_db)?;

        let amount_out = decode_quote_response(response)?;
        if index % 20 == 0 {
            println!("{} WETH -> USDC {}", volume, amount_out);
        }
    }

    measure_end(start);

    Ok(())
}

让我们运行它:

cargo run --bin revm_cached --release

# Elapsed: 3.92ms for 'revm_cached_first' (F)
# Elapsed: 387.88ms for 'revm_cached_first' (A)
# Elapsed: 72.23ms for 'revm_cached' (F)/(A)

你可以看到,我们首次获取数据的时间减少了约60%,无论是Alchemy还是全节点。对于全节点,这相当于减少了6ms,但对Alchemy,我们减少了超过500ms!如预期,剩下的99次调用的执行时间没有受到影响。

RUST_LOG=debug表明我们现在只剩下10个RPC请求,而最初的33个:

# DEBUG [alloy_rpc_client::call] sending request method=eth_getStorageAt id=10

UDSC代理代币的模拟副作用初始数据获取的步骤没有受到影响。但是,随后的99次调用速度快了约4倍,从原来的约70毫秒减少到约15毫秒。我不知道为什么这种实现会如此高效。一个不同之处在于官方报价器会计算给定代币的池地址,而在我们的版本中,我们将其作为参数提供。气体使用量为90966,而前一个例子的气体使用量为112662,即减少了约20%。但这并不能解释这种显著的速度提升。不过,我通常是因为某些东西慢得不可接受而头疼,而不是因为太快而烦恼,所以我可以接受这一点。

我们自定义报价器的另一个重要优势是,与官方报价器不同,它可以在Uniswap V3克隆版本上工作。

与其他优化方法相比,这种方法是特定于Uniswap V3的。但如果你正在使用智能合约进行模拟,那么通过查看源代码来识别业务逻辑的核心,并在例如省略安全检查的情况下重写它,可能会带来显著的性能提升。

8、EVM模拟性能改进总结

不知道你是否还记得,我曾承诺要计算Uniswap V3套利。到目前为止,我们只计算了单个池的输出金额。让我们快速回顾一下我们应用的性能改进,以及最终的例子很快就会出现。

首先,让我们回顾一下初始模拟的执行时间和RPC调用次数,这些数据用于获取必要的数据:

(F) - 本地全节点,(A) 第三方Alchemy节点:

RPC调用次数执行时间(毫秒)
eth_call_one (F)19ms
eth_call_one (A)152ms
anvil_first (F)3610ms
anvil_first (A)36759ms
revm_first (F)3310ms
revm_cached_first (F)104ms
revm_cached_first (A)10387ms

现在让我们看看在初始数据种子之后的99次后续调用的指标:

RPC调用次数执行时间(毫秒)
eth_call (F)9987ms
eth_call (A)994280ms
anvil (A)/(F)0109ms
revm (A)/(F)065ms
revm_quoter (A)/(F)016ms

现在,为了获得完整的概览,让我们将这两个表格结合起来:

RPC调用次数执行时间(毫秒)
eth_call (F)10089ms
eth_call (A)1004390ms
anvil (F)36120ms
anvil (A)36868ms
revm (F)3380ms
revm (A)331010ms
revm_cached (F)1076ms
revm_cached (A)10459ms
revm_quoter (F)1019ms
revm_quoter (A)10405ms

你可以看到,在Alchemy节点上,最坏情况下100次模拟需要超过4秒和100次RPC调用,我们已经将其减少到10次RPC调用和400毫秒。即性能提高了约10倍,并且RPC调用次数减少了约10倍。

对于本地全节点,我们将执行时间从90毫秒减少到19毫秒,将RPC调用次数从100减少到10。

你可以运行revm_validate来验证尽管有所有这些revm/模拟/缓存/报价器的复杂操作,模拟结果与标准eth_call的结果是一致的。

cargo run --bin revm_validate --release

# ...
# 15000000000000000 WETH -> USDC REVM 45146998 ETH_CALL 45146998
# 14000000000000000 WETH -> USDC REVM 42137199 ETH_CALL 42137199
# 13000000000000000 WETH -> USDC REVM 39127399 ETH_CALL 39127399
# ...

9、如何计算两个UniswapV3对之间的套利?

终于!这是使用上述技术的承诺中的Uniswap V3套利检测:

src/revm_arbitrage.rs

#[tokio::main]
async fn main() -> Result<()> {
    env_logger::init();

    let provider = ProviderBuilder::new().on_http(std::env::var("ETH_RPC_URL").unwrap().parse()?);
    let provider = Arc::new(provider);

    let volumes = volumes(U256::ZERO, one_ether().div(U256::from(10)), 100);

    let mut cache_db = init_cache_db(provider.clone());

    init_account(me(), &mut cache_db, provider.clone()).await?;
    init_account(pool_3000_addr(), &mut cache_db, provider.clone()).await?;
    init_account(pool_500_addr(), &mut cache_db, provider.clone()).await?;
    let mocked_erc20 = include_str!("bytecode/generic_erc20.hex");
    let mocked_erc20 = Bytes::from_str(mocked_erc20).unwrap();
    let mocked_erc20 = Bytecode::new_raw(mocked_erc20);

    init_account_with_bytecode(weth_addr(), mocked_erc20.clone(), &mut cache_db).await?;
    init_account_with_bytecode(usdc_addr(), mocked_erc20.clone(), &mut cache_db).await?;

    let mocked_balance = U256::MAX.div(U256::from(2));

    insert_mapping_storage_slot(
        weth_addr(),
        U256::ZERO,
        pool_3000_addr(),
        mocked_balance,
        &mut cache_db,
    )
    .await?;
    insert_mapping_storage_slot(
        usdc_addr(),
        U256::ZERO,
        pool_3000_addr(),
        mocked_balance,
        &mut cache_db,
    )
    .await?;
    insert_mapping_storage_slot(
        weth_addr(),
        U256::ZERO,
        pool_500_addr(),
        mocked_balance,
        &mut cache_db,
    )
    .await?;
    insert_mapping_storage_slot(
        usdc_addr(),
        U256::ZERO,
        pool_500_addr(),
        mocked_balance,
        &mut cache_db,
    )
    .await?;

    let mocked_custom_quoter = include_str!("bytecode/uni_v3_quoter.hex");
    let mocked_custom_quoter = Bytes::from_str(mocked_custom_quoter).unwrap();
    let mocked_custom_quoter = Bytecode::new_raw(mocked_custom_quoter);
    init_account_with_bytecode(custom_quoter_addr(), mocked_custom_quoter, &mut cache_db).await?;

    for volume in volumes.into_iter() {
        let calldata = get_amount_out_calldata(pool_500_addr(), weth_addr(), usdc_addr(), volume);
        let response = revm_revert(me(), custom_quoter_addr(), calldata, &mut cache_db)?;
        let usdc_amount_out = decode_get_amount_out_response(response)?;
        let calldata = get_amount_out_calldata(
            pool_3000_addr(),
            usdc_addr(),
            weth_addr(),
            U256::from(usdc_amount_out),
        );
        let response = revm_revert(me(), custom_quoter_addr(), calldata, &mut cache_db)?;
        let weth_amount_out = decode_get_amount_out_response(response)?;

        println!(
            "{} WETH -> USDC {} -> WETH {}",
            volume, usdc_amount_out, weth_amount_out
        );

        let weth_amount_out = U256::from(weth_amount_out);
        if weth_amount_out > volume {
            let profit = weth_amount_out - volume;
            println!("WETH profit: {}", profit);
        } else {
            println!("No profit.");
        }
    }

    Ok(())
}

大多数时候没有套利机会,但在测试时偶尔可以看到类似的结果:

cargo run --bin revm_arbitrage --release

# 100000000000000 WETH -> USDC 291455 -> WETH 100540477154459
# Profit: 540477154459 WETH

这意味着存在极小的套利利润,小于交换所需的气体成本。如果你参与MEV游戏,这不应该让人感到惊讶。热门主网池被内存池感知的机器人以单美分的利润不断套利。

所有这些努力只是为了了解实际上没有真正的利润可赚。希望你现在不会讨厌我。

你可以通过检查哪个市场有更好的买入价格并根据事件日志流的变化计算套利来改进这个示例。但这将是另一篇博客文章的故事。

10、总结

你已经完成了我五年多写作中最长的一篇文章。恭喜!

我们已经证明了REVM允许仅通过浅层理解其内部工作机制就可以集成复杂的DeFi协议。我对Uniswap V3的“tick范围”仍然一无所知。但使用描述的计算方法,我已经能够从UniV3池中获得一些套利利润。

[更新] 如果你对Revm和MEV感兴趣,请查看这篇文章长尾MEV-Revm,展示了一个名为mevlog-rs的Rust CLI工具,用于查询区块链并发现长尾MEV机会。

这些示例基于我在生产环境中使用的代码,因此我已经验证了这些技术是有效的。但我只是开始探索区块链生态系统的底层。因此,非常欢迎对示例的GitHub仓库进行修正和PR。


原文链接:How to Simulate MEV Arbitrage with REVM, Anvil and Alloy

DefiPlot翻译整理,转载请标明出处

免责声明:本站资源仅用于学习目的,也不应被视为投资建议,读者在采取任何行动之前应自行研究并对自己的决定承担全部责任。
通过 NowPayments 打赏