面向开发者 / JSON-RPC

发送一笔交易

发送很普通。读取结果不普通:回执在区块之前就已存在,而一次 revert 是以你的库无法解码的文本形式到达的。

三件可能为真的事

在大多数链上,「已发送」「已打包」和「已最终确定」会塌缩成一次等待。在这里它们是三个独立的 事件,而知道自己在检验哪一个,就是写出一个正确客户端的大半:

  • 已执行。 eth_sendRawTransaction 会一直阻塞到交易真的跑完, 所以一次成功的返回就已经意味着已执行,而不是已排队。revert 在这一刻就已知晓。
  • 已预确认。在迷你区块的调度目标之内,一份回执就存在了。它的 blockNumber 和 blockHash 仍然是 null。
  • 已确认。在 EVM 区块的调度目标上,区块封存,那两个字段被填上。

等一个区块号,就是在等错的东西

一个一直轮询到 receipt.blockNumber 不为 null 的库,是在等确认这一层,而它 真正想要的答案,也就是这笔交易成功了没有,在预确认那一层就已经可得。这不是 bug,但它确实 意味着你等得比需要的更久。回执一存在就去读 status。

最终性是第四件、独立的事:它发生在所在批次于以太坊上结算时,并且通过这些字段完全看不见。 区块标签也帮不上忙:safe 和 finalized 都解析到 latest。

发送

在本地签名,然后发送原始交易。节点不持有任何密钥,所以 eth_sendTransaction 和每一个签名方法都按设计回答 -32601。

typescript
import { createWalletClient, custom } from "viem";
import { pickleChain } from "./pickle";

const wallet = createWalletClient({ chain: pickleChain, transport: custom(window.ethereum) });

const hash = await wallet.sendTransaction({
  to: recipient,
  value: 10n ** 16n,
  // Explicit, because there is no fee market to estimate from: the base fee is
  // zero and the minimum accepted price is one wei.
  gasPrice: 1n,
});

或者对着一个裸的 EIP-1193 provider,完全不用任何库。注意是哪一边在签名:这里的 eth_sendTransaction 是由钱包处理的,钱包签名之后替你提交原始交易, 节点本身是拒绝这个方法的。

javascript
const [account] = await window.ethereum.request({ method: "eth_requestAccounts" });

const hash = await window.ethereum.request({
  method: "eth_sendTransaction",
  params: [{ from: account, to: recipient, value: "0x2386f26fc10000", gasPrice: "0x1" }],
});

读取回执

在这里缺一份回执是个问题,而不是耐心不够

eth_getTransactionReceipt 返回 null,意味着节点没有那个哈希的 回执,而在这条链上一份回执通常在迷你区块的调度目标之内就存在了,所以等上一两秒之后仍是 null,是一个该去排查的信号,而不是该继续等的信号。

它按设计也是有歧义的:一份已经被窗口逐出的回执回答的 null,和一份从 未存在过的回执一模一样。这正是为什么在这里用长退避轮询是错的形状:两次尝试之间等得太久, 回执就可能在它们之间消失。

javascript
// Poll early and tightly: the receipt exists almost immediately, and a long
// backoff risks the window evicting it between two attempts.
async function receiptFor(hash, tries = 40) {
  for (let i = 0; i < tries; i += 1) {
    const receipt = await call("eth_getTransactionReceipt", [hash]);
    if (receipt) return receipt;
    await new Promise((r) => setTimeout(r, 100));
  }
  throw new Error("no receipt after 4s - check the node, not the transaction");
}

const receipt = await receiptFor(hash);

// "0x1" succeeded, "0x0" reverted. Both are known before the block seals.
if (receipt.status !== "0x1") throw new Error("transaction reverted");

// Only if you actually need the block: blockNumber is null until the EVM seal.

用 viem 的话,waitForTransactionReceipt 会替你做这件事,但把 pollingInterval 设低,并且不要因为觉得等得越久越安全就调高它的超时。在这里并非 如此。

那份回执上有两个字段的含义与别处不同。cumulativeGasUsed 是这笔交易自己的 gas, 而不是一个区块内的累计值;每条日志上的 logIndex 是按交易而不是按区块编号的, 所以 (blockNumber, logIndex) 不是一个唯一键。如果你要存事件,请把交易哈希也 加进去。

当它 revert 时

你的库无法解码那个原因

一次 revert 是以消息文本的形式回来的,execution reverted: 0x…,而 error.data 从不被填充。每个库都从 error.data 里解码自定义错误和 revert 字符串,所以在这条链上它们全都做不到:viem 和 ethers 都会把原始消息抛出来,而不是 一个有名字的错误。

返回数据就在消息里,所以你可以自己解码:

typescript
import { decodeErrorResult } from "viem";

try {
  await client.simulateContract({ /* … */ });
} catch (err) {
  const hex = String(err.message).match(/0x[0-9a-fA-F]+/)?.[0];
  if (hex && hex.length > 2) {
    // Decode against your own ABI's error definitions.
    const decoded = decodeErrorResult({ abi: myAbi, data: hex as `0x${string}` });
    console.error(decoded.errorName, decoded.args);
  }
}

还有一个值得知道的传输差异:同一个 revert 在 HTTP 上是 -32000,在 WebSocket 上 是 -32603,因为那个 socket 会重新包装每一个非发送类错误。一个按错误码分支的 客户端需要知道自己走的是哪种传输。

Gas

把 gasPrice 显式设成 1 wei。这里没有可供估算的费用市场:基础费为 零,eth_gasPrice 回答一 wei,而一 wei 也是这条链能接受的最低价。

eth_estimateGas 能用,也会正经地模拟,并加上通常的余量,但它会忽略你传给它的 gas、gasPrice、nonce 和区块参数,永远以 30,000,000 的区块上限、对着最新状态模拟。另外不要去读 eth_feeHistory:它返回的是常量而不是测量结果。

两个会以 -32005 呈现的限制

超过 128 KiB 的原始交易在被解码之前就会因体积被拒绝,而准入队列对每个发送者只保存 64 笔 待处理交易。两者都回答 -32005,它表示一个限制而不是一个错误:请退避后重试,而不是把它当成一次失败。