如果你的应用程序需要对余额、符号或储备信息进行几十次 eth_call 调用,你将浪费大量时间并很快耗尽RPC服务的速率限制预算。
Multicall 正是为解决这个问题而生。它是一个部署在链上的轻量级合约,能够一次性执行多个 staticcall(静态调用),并从一个统一的区块快照中返回所有结果,从而保证了数据的一致性。
本文将带你了解如何将多个读取操作打包进一次 eth_call,通过 Polygon Amoy 测试网上的 Multicall3 合约来执行,并使用 Go 语言解码返回结果。我们还会将其与 JSON-RPC 批处理(batching)进行对比,并解释为什么只有 Multicall 能确保所有结果的状态一致性。
使用 Multicall 进行批量调用
当你的应用需要进行大量视图函数调用时——例如查询余额(balanceOf)、授权额度(allowance)、代币符号(symbol)或流动性池储备金(reserves)——逐个执行它们意味着你需要面对:
- 额外的网络延迟(N次网络往返)。
- 更高的服务商费用(N个独立请求)。
- 更多的速率限制困扰(突发请求易被限流)。
- 以及可能出现的数据快照不一致问题(结果来自略有差异的区块)。
Multicall 一举解决了上述所有痛点。它是一个微型合约,能够一次性执行大量 staticcall 并返回全部结果。这意味着你只需发起一次 eth_call,就能获得 N 个答案,并且所有答案都来自同一个区块。
为什么说 Multicall 必不可少
- 更少的 RPC 请求 → 更不容易触发速率限制,同时降低成本。
- 更低的延迟 → 一次网络往返替代多次。
- 一致的状态视图 → 所有读取都基于同一个区块的状态,避免了“半新半旧”的数据混杂。
如何用 Go 实现 Multicall
其核心流程是:将每个读取调用(例如 balanceOf(user))进行 ABI 编码,打包到一个数组中,然后通过一次 eth_call 调用 Multicall 合约的 aggregate 或 tryAggregate 函数,最后解码每一块返回数据。
注意: 你可以在 multicall3.com 找到 Multicall 在各网络的部署地址。
完整示例代码如下:
package main
import (
"context"
"fmt"
"log"
"math/big"
"strings"
"github.com/ethereum/go-ethereum"
"github.com/ethereum/go-ethereum/accounts/abi"
"github.com/ethereum/go-ethereum/common"
"github.com/ethereum/go-ethereum/ethclient"
)
const multicallABI = `[ { "inputs":[ {"internalType":"bool","name":"requireSuccess","type":"bool"}, { "components":[ {"internalType":"address","name":"target","type":"address"}, {"internalType":"bytes","name":"callData","type":"bytes"} ],
"internalType":"struct Call[]",
"name":"calls",
"type":"tuple[]"
}
],
"name":"tryAggregate",
"outputs":[
{
"components":[
{"internalType":"bool","name":"success","type":"bool"},
{"internalType":"bytes","name":"returnData","type":"bytes"}
],
"internalType":"struct Result[]",
"name":"returnData",
"type":"tuple[]"
}
],
"stateMutability":"nonpayable",
"type":"function"
}
]`
const erc20ABI = `[ {"name":"balanceOf","type":"function","stateMutability":"view","inputs":[{"name":"owner","type":"address"}],"outputs":[{"type":"uint256"}]},
{"name":"symbol","type":"function","stateMutability":"view","inputs":[],"outputs":[{"type":"string"}]},
{"name":"decimals","type":"function","stateMutability":"view","inputs":[],"outputs":[{"type":"uint8"}]}
]`
var (
RPC = "https://polygon-amoy.drpc.org"
MULTICALL_ADDR = common.HexToAddress("0xcA11bde05977b3631167028862bE2a173976CA11") // Multicall3
TOKEN_ADDR = common.HexToAddress("0x0Fd9e8d3aF1aaee056EB9e802c3A762a667b1904") // Amoy 上的 LINK
USER = common.HexToAddress("0x7F8b1ca29F95274E06367b60fC4a539E4910FD0c")
)
func main() {
ctx := context.Background()
client, err := ethclient.Dial(RPC)
if err != nil {
log.Fatalf("dial rpc: %v", err)
}
mabi, err := abi.JSON(strings.NewReader(multicallABI))
if err != nil {
log.Fatalf("parse multicall abi: %v", err)
}
eabi, err := abi.JSON(strings.NewReader(erc20ABI))
if err != nil {
log.Fatalf("parse erc20 abi: %v", err)
}
// 为 ERC20 读取构建 calldata
balData, err := eabi.Pack("balanceOf", USER)
if err != nil {
log.Fatalf("pack balanceOf: %v", err)
}
symData, err := eabi.Pack("symbol")
if err != nil {
log.Fatalf("pack symbol: %v", err)
}
decData, err := eabi.Pack("decimals")
if err != nil {
log.Fatalf("pack decimals: %v", err)
}
type Call struct {
Target common.Address
CallData []byte
}
calls := []Call{
{Target: TOKEN_ADDR, CallData: balData},
{Target: TOKEN_ADDR, CallData: symData},
{Target: TOKEN_ADDR, CallData: decData},
}
input, err := mabi.Pack("tryAggregate", false, calls)
if err != nil {
log.Fatalf("pack tryAggregate: %v", err)
}
// 单次 eth_call 调用
msg := ethereum.CallMsg{To: &MULTICALL_ADDR, Data: input}
out, err := client.CallContract(ctx, msg, nil)
if err != nil {
log.Fatalf("CallContract (eth_call): %v", err)
}
// 解码结果
var results []struct {
Success bool
ReturnData []byte
}
if err := mabi.UnpackIntoInterface(&results, "tryAggregate", out); err != nil {
log.Fatalf("unpack tryAggregate: %v", err)
}
if len(results) != 3 {
log.Fatalf("unexpected results len: %d", len(results))
}
var (
balance *big.Int
symbol string
decimals uint8
)
// 0: balanceOf
if results[0].Success {
vals, err := eabi.Unpack("balanceOf", results[0].ReturnData)
if err != nil {
log.Fatalf("unpack balanceOf: %v", err)
}
balance = vals[0].(*big.Int)
} else {
log.Printf("balanceOf failed")
}
// 1: symbol
if results[1].Success {
vals, err := eabi.Unpack("symbol", results[1].ReturnData)
if err != nil {
log.Fatalf("unpack symbol: %v", err)
}
symbol = vals[0].(string)
} else {
log.Printf("symbol failed")
}
// 2: decimals
if results[2].Success {
vals, err := eabi.Unpack("decimals", results[2].ReturnData)
if err != nil {
log.Fatalf("unpack decimals: %v", err)
}
decimals = vals[0].(uint8)
} else {
log.Printf("decimals failed")
}
fmt.Printf("Symbol: %s, Decimals: %d\n", symbol, decimals)
if balance != nil {
fmt.Printf("Balance(%s): %s\n", TOKEN_ADDR.Hex(), balance.String())
}
}
运行上述代码,你将得到类似如下的输出:
Symbol: LINK, Decimals: 18
Balance(0x0Fd9e8d3aF1aaee056EB9e802c3A762a667b1904): 38000000000000000000
与 JSON-RPC 批处理(Batching)的对比
部分节点服务商支持 JSON-RPC 批处理,即允许在一个 HTTP 请求中发送一个 eth_* 方法数组。这对于批量获取余额、区块头或交易收据等场景可能比较方便。
但对于 eth_call,通常不推荐依赖这种方式。因为节点在内部仍然会单独执行每一个调用,你无法保证所有结果都来自完全相同的区块状态。如果你的应用关心数据快照的一致性以及节省速率限制,链上 Multicall 是更优的选择。
注意: JSON-RPC 批处理有助于减少 HTTP 层面的请求数量,但只有 Multicall 才能保证所有调用在同一个区块中具有完全一致的状态快照。
总结
Multicall 是生态系统中那些默默无闻却至关重要的基础工具之一,它为几乎所有的数据仪表板、分析应用和链上数据服务提供了支持。通过单个 eth_call,它为你带来了数据一致性、更低延迟和更少的 RPC 管理负担。
虽然 JSON-RPC 批处理可以减少 HTTP 请求,但它不能保证原子性的状态视图。Multicall 可以。通过在链上以一个静态的、统一的上下文执行读取,你可以获得原子级的、同步的状态快照——这是构建精确数据管道或实时价格显示等功能所必需的。
将 Multicall 与事件监听、交易追踪等技术结合,它构成了每一位旨在构建高效、实时以太坊应用开发者的核心工具包。希望这篇指南能帮助你更好地理解和应用这一强大工具,提升你的区块链开发效率。如果你想了解更多此类实战技巧,欢迎关注云栈社区的更新。