本文介绍了以太坊开发中的一些实用技巧,包括理解和使用事件(logs)、实时流式传输链上活动、无需事件捕获ETH转移以及使用Multicall进行批量调用,旨在帮助开发者更有效地进行以太坊开发和调试。
在以太坊内部深入研究了几个月后,我开始收集一系列能让生活更轻松的开发者技巧。这些技巧不会出现在文档中,但可以节省你数小时的调试时间。
在这篇文章中,我将分享我最喜欢的一些技巧:
1. 理解事件
2. 实时流式传输链上活动
3. 无需事件捕获 ETH 转账
4. 使用 Multicall 批量处理调用
这些工具会让你感觉自己解锁了以太坊的 X 射线视觉。
当大多数开发者想知道 EVM 上发生了什么时,他们会从 事件 (也称为日志)开始。
event Transfer(address indexed from, address indexed to, uint256 value);
它们是事件的 搜索关键字。它们允许你/节点提供者在不读取所有内容的情况下过滤日志。每个事件始终具有:
Topic[0] → 事件签名哈希,例如:
keccack256("Transfer(address,address,uint256)")
Topic[1..3] → 最多三个标记为 indexed
的参数,它们始终存储为固定的 32 字节值。这意味着你可以轻松地在 RPC 层直接查询“来自 Alice 的所有转账”或“给定交易对的所有交换”。
所有未标记为 indexed
的内容都将打包到 data 字段中。
数据 不可搜索,你需要获取日志并自己解码它们。除了 gas 之外,你可以包含多少个非索引字段没有硬性限制。
curl -s -X POST https://polygon-amoy-bor-rpc.publicnode.com \
-H "Content-Type: application/json" \
--data '{
"jsonrpc":"2.0",
"method":"eth_getLogs",
"params":[{\
"fromBlock":"0x182e86c",\
"toBlock":"0x182e86c",\
"address":"0x0fd9e8d3af1aaee056eb9e802c3a762a667b1904",\
"topics":[\
"0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef",\
"0x0000000000000000000000007f8b1ca29f95274e06367b60fc4a539e4910fd0c"\
]\
}],
"id":1
}' | jq
在这里,我们搜索区块 0x182e86c 上的 Transfer 事件,其中 0x7f8b1ca29f95274e06367b60fc4a539e4910fd0c 发送了 LINK 代币。
响应将如下所示:
{
"jsonrpc": "2.0",
"id": 1,
"result": [\
{\
"address": "0x0fd9e8d3af1aaee056eb9e802c3a762a667b1904",\
"topics": [\
"0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef",\
"0x0000000000000000000000007f8b1ca29f95274e06367b60fc4a539e4910fd0c",\
"0x0000000000000000000000002a51ae0ad42dc7d2eb89462a7d41e79502bcf697"\
],\
"data": "0x0000000000000000000000000000000000000000000000000de0b6b3a7640000",\
"blockNumber": "0x182e86c",\
"transactionHash": "0x330e48c4c3adcc17b0819b7bf7344bb5010beee59551713231e977508ee1b236",\
"transactionIndex": "0x2",\
"blockHash": "0xb48487df956cb9fd6cc9750e2438b03c99d146910a2a1159850712c38ee85681",\
"logIndex": "0x3",\
"removed": false\
}\
]
}
我们在这里看到:
address
→ 发出事件的合约
0x0fd9…b1904
= Polygon Amoy 上的 LINK 代币。blockNumber
→ 包含交易的区块 (十六进制)。
0x182e86c
= 25356396
(十进制)。transactionHash
→ 触发此日志的交易的哈希。
logIndex
→ 此日志在区块中的位置(事件已排序)。
topics
→ 索引参数:
topics[0]
= 事件签名:0xddf252ad...
是 keccak256("Transfer(address,address,uint256)")
。
topics[1]
= from
地址,填充为 32 字节:0x7f8b1c…fd0c
= 发送者。
topics[2]
= to
地址,填充为 32 字节:0x2a51ae…f697
= 接收者。
data
→ 非索引参数(在本例中,只有 value
)
0x...0de0b6b3a7640000
= 十进制的 1000000000000000000
= 1.0 LINK。使用 eth_getLogs
捕获过去的日志非常棒,但是当你实时流式传输更改时,产品会感觉更活跃。为此,你将使用 WebSocket 订阅:
eth_subscribe
。removed: true
)。下面是使用 go-ethereum
的 Go 代码片段:
Transfer
事件,可以选择按 from
/ to
进行过滤package main
import (
"context"
"fmt"
"log"
"github.com/ethereum/go-ethereum"
"github.com/ethereum/go-ethereum/common"
"github.com/ethereum/go-ethereum/core/types"
"github.com/ethereum/go-ethereum/ethclient"
)
func main() {
// Connect to Polygon Amoy WS endpoint
client, err := ethclient.Dial("wss://polygon-amoy-bor-rpc.publicnode.com")
if err != nil {
log.Fatal(err)
}
defer client.Close()
// Transfer event signature
transferSig := common.HexToHash("0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef")
// Filter: all Transfer events from this token
query := ethereum.FilterQuery{
Addresses: []common.Address{
common.HexToAddress("0x0fd9e8d3af1aaee056eb9e802c3a762a667b1904"), // LINK on Amoy
},
Topics: [][]common.Hash{
{transferSig},
{common.HexToHash("0x0000000000000000000000007f8b1ca29f95274e06367b60fc4a539e4910fd0c")}, // topic[1]: from
},
}
logs := make(chan types.Log)
// Subscribe to logs
sub, err := client.SubscribeFilterLogs(context.Background(), query, logs)
if err != nil {
log.Fatal(err)
}
fmt.Println("Listening for Transfer events...")
// Print events as they come in
for {
select {
case err := <-sub.Err():
log.Fatal(err)
case vLog := <-logs:
fmt.Printf("New Transfer log in block %d, tx %s\n", vLog.BlockNumber, vLog.TxHash.Hex())
fmt.Println("Raw topics:", vLog.Topics)
fmt.Println("Data:", vLog.Data)
}
}
}
实时输出将如下所示:
我们可以在这里看到 索引主题,其中包括方法签名、from、to 和包含未索引 value 的数据 blob 字段。
事件很棒,但它们并不是全部。
合约可以使用简单的 CALL{value: ...}
发送 原生 ETH,并且根本 不发出任何日志。除非合约 选择 emit
,否则该移动不会显示在 eth_getLogs
中。因此,如果你只收听事件,你将会错过:
CALL{value}
退款SELFDESTRUCT
发送合约的余额)有两种可靠的方法可以查看 ETH 的转移:
如果 EOA 直接将 ETH 发送到 to
,则无需日志即可看到:
tx.to
和 tx.value
(wei)。from
→ to
转移。注意事项:
to
是一个合约并且它进行 进一步 付款,那么这些是 内部的(见下文)。要查看 在 交易中移动的 ETH(合约 → 用户),你必须解析 调用树。使用节点追踪 API(正如我们在 之前的文章 中学到的)。
你要寻找 具有非零 value
的调用:
curl -s -X POST <YOUR-BLOCKCHAIN-NODE-URL>\
-H "Content-Type: application/json" \
--data '{
"jsonrpc":"2.0",
"id":1,
"method":"trace_replayTransaction",
"params":[\
"<tx-hash>",\
["trace"]\
]
}' \
| jq -r '.result.trace[]
| select(.action.value!="0x0")
| {type, from:.action.from, to:.action.to, value:.action.value}'
输出将如下所示:
{
"type": "call",
"from": "<addr1>",
"to": "<addr2>",
"value": "0x22e92f1cfbaaacd5d"
}
{
"type": "call",
"from": "<addr3>",
"to": "<addr4>",
"value": "0x22e92f1cfbaaacd5d"
}
value != 0
的对象。你还可以处理:
"type": "SELFDESTRUCT"
→ 具有 to
和 value
支付。"type": "DELEGATECALL"
→ value 始终为 0 (它转发上下文),因此跳过,除非你跟踪副作用(你可以在委托后看到内部调用)。注意: 你可以只递归地解析每个“节点”,看看 value != 0。
function collectEthTransfers(node):
if node.value > 0:
record(from=node.from, to=node.to, value=node.value, type=node.type)
for child in node.calls:
collectEthTransfers(child)
当你的应用程序需要 大量的 view 调用:余额、授权、符号、池储备,逐个执行意味着:
Multicall 修复了所有这些问题。它是一个微小的合约,可以一次执行多个 staticcall
,并返回所有结果,因此你可以进行 一个 eth_call
,获得 N 个答案,全部在同一区块中。
你 ABI 编码每个读取(例如,balanceOf(user)
),将它们打包到一个数组中 → 通过 一个 eth_call
调用 Multicall 的 aggregate/tryAggregate
→ 解码每个返回 blob。
注意: 你可以在 这里 找到 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") // LINK on Amoy
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)
}
// Build calldata for ERC20 reads
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)
}
// Single 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)
}
// Decode results
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
某些提供商支持在一个 HTTP 调用中发送一组 eth_*
请求。这对于获取余额、区块头或交易收据之类的操作非常有用。但是对于 eth_call
,通常不建议这样做。节点仍然分别执行每个调用,并且你失去了结果来自完全相同区块的保证。如果你关心一致的快照和节省速率限制,链上 Multicall 是更好的选择。
注意: JSON-RPC 批量处理有助于减少 HTTP 请求,但只有 Multicall 才能保证所有调用在同一区块上的一致快照。
有了这些技巧,你现在就有了构建仪表板、使你的警报实时以及使你的 RPC 使用效率更高的构建块。在你的下一个 dApp 中尝试其中一个技巧,你将立即感受到不同。
- 原文链接: medium.com/@andrey_obruc...
- 登链社区 AI 助手,为大家转译优秀英文文章,如有翻译不通的地方,还请包涵~
如果觉得我的文章对您有用,请随意打赏。你的支持将鼓励我继续创作!