以太坊开发技巧:捕获隐藏转移、实时事件和多重调用

本文介绍了以太坊开发中的一些实用技巧,包括理解和使用事件(logs)、实时流式传输链上活动、无需事件捕获ETH转移以及使用Multicall进行批量调用,旨在帮助开发者更有效地进行以太坊开发和调试。

在以太坊内部深入研究了几个月后,我开始收集一系列能让生活更轻松的开发者技巧。这些技巧不会出现在文档中,但可以节省你数小时的调试时间。

在这篇文章中,我将分享我最喜欢的一些技巧:

1. 理解事件

2. 实时流式传输链上活动

3. 无需事件捕获 ETH 转账

4. 使用 Multicall 批量处理调用

这些工具会让你感觉自己解锁了以太坊的 X 射线视觉。

理解事件

当大多数开发者想知道 EVM 上发生了什么时,他们会从 事件 (也称为日志)开始。

什么是事件:

  • 智能合约可以在执行期间 发出事件
  • 事件不存储在合约存储中,它们存在于 交易回执日志 中。

例子:一个 ERC-20 代币转账事件:

event Transfer(address indexed from, address indexed to, uint256 value);

每个事件都有:

  • 一个 主题列表 (索引字段,用于过滤):

它们是事件的 搜索关键字。它们允许你/节点提供者在不读取所有内容的情况下过滤日志。每个事件始终具有:

Topic[0] → 事件签名哈希,例如:

keccack256("Transfer(address,address,uint256)")

Topic[1..3] → 最多三个标记为 indexed 的参数,它们始终存储为固定的 32 字节值。这意味着你可以轻松地在 RPC 层直接查询“来自 Alice 的所有转账”或“给定交易对的所有交换”。

  • 一个 数据 blob (未索引字段,存储为原始字节):

所有未标记为 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

扫描器上的事件将如下所示:

https://amoy.polygonscan.com/tx/0x330e48c4c3adcc17b0819b7bf7344bb5010beee59551713231e977508ee1b236#eventlog

实时流式传输链上活动

使用 eth_getLogs 捕获过去的日志非常棒,但是当你实时流式传输更改时,产品会感觉更活跃。为此,你将使用 WebSocket 订阅

  • 协议: 通过 WS,而不是 HTTP 的 eth_subscribe
  • 你会得到什么: 一旦挖掘出交易,就会推送匹配日志的流。
  • 要注意什么: 临时断开连接、RPC 重启和链重组(日志可以标记为 removed: true)。

下面是使用 go-ethereum 的 Go 代码片段:

示例 : 从代币流式传输 ERC-20 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 字段

无需事件捕获 ETH 转账

事件很棒,但它们并不是全部。

合约可以使用简单的 CALL{value: ...} 发送 原生 ETH,并且根本 不发出任何日志。除非合约 选择 emit,否则该移动不会显示在 eth_getLogs 中。因此,如果你只收听事件,你将会错过:

  • 来自路由器/桥/国库的付款给用户
  • 通过 CALL{value} 退款
  • 自毁付款(SELFDESTRUCT 发送合约的余额)
  • 隐藏在较大交易中的任何内部 ETH 转移

有两种可靠的方法可以查看 ETH 的转移:

顶级 ETH 转账(简单):查看交易本身

如果 EOA 直接将 ETH 发送到 to,则无需日志即可看到:

  • 读取 tx.totx.value (wei)。
  • 如果交易 成功,则该数量的 ETH 从 fromto 转移。

注意事项:

  • 如果交易 回滚,则没有任何转移。
  • 如果 to 是一个合约并且它进行 进一步 付款,那么这些是 内部的(见下文)。

内部 ETH 转账(真正重要的):追踪执行

要查看 交易中移动的 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" → 具有 tovalue 支付。
  • "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)

使用 Multicall 批量处理调用

当你的应用程序需要 大量的 view 调用:余额、授权、符号、池储备,逐个执行意味着:

  • 额外的 延迟 (N 次往返),
  • 更高的 提供商账单 (N 个请求),
  • 更多的 速率限制 痛苦(突发流量受到限制),
  • 不一致的快照 (来自略有不同的区块的结果)。

Multicall 修复了所有这些问题。它是一个微小的合约,可以一次执行多个 staticcall,并返回所有结果,因此你可以进行 一个 eth_call,获得 N 个答案,全部在同一区块中

为什么 Multicall 是必备品

  • 更少的 RPC → 更少的速率限制命中 + 更低的成本。
  • 更低的延迟 → 一次往返而不是多次。
  • 一致的状态所有同一区块 的读取(没有“半旧”数据)。

如何完成

你 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

JSON-RPC 批量处理

某些提供商支持在一个 HTTP 调用中发送一组 eth_* 请求。这对于获取余额、区块头或交易收据之类的操作非常有用。但是对于 eth_call,通常不建议这样做。节点仍然分别执行每个调用,并且你失去了结果来自完全相同区块的保证。如果你关心一致的快照和节省速率限制,链上 Multicall 是更好的选择

注意: JSON-RPC 批量处理有助于减少 HTTP 请求,但只有 Multicall 才能保证所有调用在同一区块上的一致快照。

总结

有了这些技巧,你现在就有了构建仪表板、使你的警报实时以及使你的 RPC 使用效率更高的构建块。在你的下一个 dApp 中尝试其中一个技巧,你将立即感受到不同。

  • 原文链接: medium.com/@andrey_obruc...
  • 登链社区 AI 助手,为大家转译优秀英文文章,如有翻译不通的地方,还请包涵~
点赞 0
收藏 0
分享
本文参与登链社区写作激励计划 ,好文好收益,欢迎正在阅读的你也加入。

0 条评论

请先 登录 后评论
Andrey Obruchkov
Andrey Obruchkov
江湖只有他的大名,没有他的介绍。