别让RPC请求限制拖慢你的DApp:使用Multicall3实现高效批量查询

对于每一位与以太坊虚拟机(EVM)兼容的区块链进行交互的开发者来说,远程过程调用(RPC)是我们与区块链对话的桥梁。然而,一个普遍存在的痛点是:免费的RPC节点服务通常伴随着请求频率和总量的限制。当你的去中心化应用(DApp)用户量增长,或者需要频繁从链上获取大量数据时,这些限制很快就会成为瓶颈,导致数据获取延迟、应用性能下降,甚至服务中断。

想象一下,你需要为一个DeFi仪表盘展示100个不同地址的DAI代币余额。传统的方法是发送100次独立的eth_call请求到RPC节点。这种方式不仅效率低下,而且会迅速消耗掉你在免费RPC服务商那里的每日请求配额。

幸运的是,我们有一个强大的工具可以解决这个核心问题——Multicall3

什么是 Multicall3?

Multicall3 是一个由 MakerDAO 团队实现的智能合约,其主要功能是将多个独立的合约只读函数调用捆绑到一笔交易中执行。这意味着,你可以将成百上千个查询请求打包成一个单一的eth_call JSON RPC请求发送给节点。节点执行这个聚合调用后,会将所有结果一次性返回。

更棒的是,Multicall3 合约被部署在了超过250条EVM兼容链上的同一个地址:0xcA11bde05977b3631167028862bE2a173976CA11,这使得它成为一个极其方便和标准化的解决方案。

Multicall3 如何节约RPC请求?

Multicall3 的工作原理相当直观。与其让你的应用程序依次发送多个RPC请求,不如将这些请求在客户端进行预处理和打包:

  1. 打包调用: 你的应用将收集所有需要执行的调用,例如,多个地址的 balanceOf(address) 请求。每个调用都包含目标合约地址和编码后的函数调用数据。

  2. 单一请求: 应用将这个打包好的“调用数组”作为参数,向 Multicall3 合约发起一个单一的 aggregate3 函数调用。

  3. 批量执行与返回: Multicall3 合约在链上接收到这个请求后,会按顺序执行数组中的每一个调用,并收集每个调用的返回数据。

  4. 统一响应: 最后,Multicall3 合约将所有调用的结果打包成一个数组,并通过这一个RPC响应返回给你的应用。

通过这种方式,原本需要100次RPC请求才能完成的任务,现在只需要1次。这极大地降低了对RPC节点的请求压力,使你能够更从容地应对免费RPC服务的请求限制。

使用 Multicall3 的核心优势

除了最核心的节约RPC请求次数之外,使用Multicall3还带来了其他显著的好处:

  • 提升性能和速度: 大幅减少客户端与节点之间的网络往返次数,从而加快数据获取速度,提升DApp的响应性能。

  • 保证数据原子性: 所有在同一次Multicall调用中返回的数据都来自同一个区块。这确保了数据的一致性,避免了因多次请求分布在不同区块而导致的数据状态不一致问题。

  • 降低开发复杂性: 开发者无需再管理和协调大量的并发异步请求,代码逻辑变得更加简洁和易于维护。

实战:批量查询代币余额

让我们通过一个简单的代码示例,看看如何在 ethers.js 中利用Multicall3批量查询代币余额。

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

// 使用你的RPC提供商URL
const provider = new ethers.providers.JsonRpcProvider("YOUR_RPC_URL");

// Multicall3的固定合约地址
const multicallAddress = "0xcA11bde05977b3631167028862bE2a173976CA11";

// Multicall3 ABI (仅需 aggregate3 函数)
const multicallAbi = [
    {
        "inputs": [
            {
                "components": [
                    { "internalType": "address", "name": "target", "type": "address" },
                    { "internalType": "bool", "name": "allowFailure", "type": "bool" },
                    { "internalType": "bytes", "name": "callData", "type": "bytes" }
                ],
                "internalType": "struct Multicall3.Call3[]",
                "name": "calls",
                "type": "tuple[]"
            }
        ],
        "name": "aggregate3",
        "outputs": [
            {
                "components": [
                    { "internalType": "bool", "name": "success", "type": "bool" },
                    { "internalType": "bytes", "name": "returnData", "type": "bytes" }
                ],
                "internalType": "struct Multicall3.Result[]",
                "name": "returnData",
                "type": "tuple[]"
            }
        ],
        "stateMutability": "view",
        "type": "function"
    }
];

// 标准ERC20 ABI (仅需 balanceOf 函数)
const erc20Abi = [
    {
        "constant": true,
        "inputs": [{ "name": "_owner", "type": "address" }],
        "name": "balanceOf",
        "outputs": [{ "name": "balance", "type": "uint256" }],
        "type": "function"
    }
];

const multicallContract = new ethers.Contract(multicallAddress, multicallAbi, provider);
const erc20Interface = new ethers.utils.Interface(erc20Abi);

async function getBatchBalances(tokenAddress, userAddresses) {
    const calls = userAddresses.map(address => ({
        target: tokenAddress,
        allowFailure: true, // 设置为true来允许部分调用失败而不影响整个批量调用
        callData: erc20Interface.encodeFunctionData("balanceOf", [address])
    }));

    try {
        const results = await multicallContract.aggregate3(calls);

        const balances = results.map((result, i) => {
            if (result.success) {
                const balance = erc20Interface.decodeFunctionResult("balanceOf", result.returnData)[0];
                return {
                    address: userAddresses[i],
                    balance: ethers.utils.formatUnits(balance, 18) // 假设代币精度为18
                };
            } else {
                return {
                    address: userAddresses[i],
                    balance: "Error fetching balance"
                };
            }
        });

        console.log(balances);

    } catch (error) {
        console.error("批量查询失败:", error);
    }
}

// --- 使用示例 ---
const DAI_ADDRESS = "0x6B175474E89094C44Da98b954EedeAC495271d0F"; // 以太坊主网DAI地址
const addressesToQuery = [
    "0xAb5801a7D398351b8bE11C439e05C5B3259aeC9B", // Vitalik Buterin的地址
    "0x742d35Cc6634C0532925a3b844Bc454e4438f44e", // Bitfinex 2 地址
    "0x000000000000000000000000000000000000dead"  // 一个无效地址
];

getBatchBalances(DAI_ADDRESS, addressesToQuery);

示例输出

当运行上述代码时,你将在控制台中看到类似如下的输出。这清楚地展示了我们如何通过一次RPC调用,获取了多个地址的余额信息,甚至优雅地处理了其中一个失败的调用。

[  {    "address": "0xAb5801a7D398351b8bE11C439e05C5B3259aeC9B",    "balance": "2.11218825"  },  {    "address": "0x742d35Cc6634C0532925a3b844Bc454e4438f44e",    "balance": "0.0"  },  {    "address": "0x000000000000000000000000000000000000dead",    "balance": "Error fetching balance"  }]

(注意: 余额会根据链上实时数据而变化。)

结论

面对日益增长的链上数据交互需求和普遍存在的RPC请求限制,Multicall3提供了一个简单而极其有效的解决方案。通过将多个查询请求聚合为一次调用,它不仅能够帮助你轻松绕开免费RPC服务的限制,还能显著提升你的DApp性能,并简化代码逻辑。对于任何需要进行批量数据读取的应用场景,掌握并使用Multicall3都将是一项至关重要的优化技能。