题目
用户提交一笔合约写交易时,有时在钱包弹窗前就报错,有时已经拿到交易哈希,最后 receipt.status 却是失败。前端应该怎样区分、诊断和提示这两类失败?为什么失败 receipt 通常不能直接给出 revert reason?发送前模拟能解决什么,又不能保证什么?
考察目标
- 是否能区分模拟、签名、广播和链上执行阶段的错误。
- 是否会用完整 ABI 解码 Solidity 内置错误与 custom error。
- 是否理解模拟只针对某一时刻和某组调用上下文,不能保证正式交易成功。
考察发送前模拟、自定义错误解码、失败 receipt 的信息边界以及稳定的前端错误分层。
用户提交一笔合约写交易时,有时在钱包弹窗前就报错,有时已经拿到交易哈希,最后 receipt.status 却是失败。前端应该怎样区分、诊断和提示这两类失败?为什么失败 receipt 通常不能直接给出 revert reason?发送前模拟能解决什么,又不能保证什么?
前端先用与正式交易相同的 account、to、参数、value 和目标链执行模拟。viem 的 simulateContract 基于调用执行,成功时返回可直接交给 writeContract 的 request,失败时可从错误链中查找 ContractFunctionRevertedError;ABI 包含 custom error 定义时才能可靠解码其名称和参数。若交易已经广播并得到 hash,就必须以 receipt 为准:status = reverted 说明链上执行失败,但 receipt 主要记录状态、Gas 和日志,不保证携带 revert data。需要原因时优先保留发送前模拟结果,必要时在相同区块上下文重放调用或使用 trace 服务。模拟与上链之间状态、base fee、余额、nonce 和调用路径都可能变化,所以模拟是风险前置检查,不是成功承诺。
“交易失败”至少包含四类不同问题:
它们的恢复动作不同。用户拒绝不应显示成“合约执行失败”,RPC 超时也不能直接断言交易未广播;链上回滚则不能简单重试,因为相同条件下很可能再次失败。
一次有意义的模拟需要尽量与正式请求一致:
chainId 与目标合约地址。msg.sender 会影响权限和余额判断。value 和必要的费用或状态覆盖。eth_call 可以执行调用但不创建链上交易,也不实际消耗用户 Gas。eth_estimateGas 主要回答执行需要多少 Gas,节点也可能在估算时暴露回滚错误。viem 的 simulateContract 在 ABI 层封装调用,能够模拟写函数、返回函数结果,并构造类型安全的 write request。
如果模拟成功后重新手写一套交易参数,模拟和真实请求仍可能漂移。更稳妥的方式是直接把返回的 request 交给 writeContract。
Solidity 常见回滚数据包括:
Error(string):传统字符串原因。Panic(uint256):断言失败、算术溢出等 Panic code。要解码 custom error,前端 ABI 必须包含对应的 error 项。代理合约场景还可能需要实现合约的 ABI,而不只是代理表面 ABI。来自未知合约或外部调用的错误数据不能天然视为可信文案,应该映射为产品维护的安全提示,而不是把任意字符串直接当 HTML 渲染。
交易 receipt 的职责是证明交易执行结果并记录 Gas、日志和区块位置。失败时状态为回滚,回滚产生的日志也不会保留;标准 receipt 并不要求附带完整 EVM revert data。
需要进一步诊断时可以:
eth_call,但历史状态、交易顺序和同区块上下文可能无法完全复现。因此 UI 的错误体系不应依赖“交易失败后一定能还原精确原因”。
模拟与交易真正执行之间可能发生:
产品文案应是“模拟通过,可以发起交易”,不能写成“交易保证成功”。对滑点、deadline 等时间敏感参数,模拟后还要限制签名等待时间或在提交前重新计算。
import { BaseError, ContractFunctionRevertedError } from "viem";
try {
const { request } = await publicClient.simulateContract({
account,
address: routerAddress,
abi: routerAbi,
functionName: "swap",
args: [amountIn, minAmountOut, deadline],
});
const hash = await walletClient.writeContract(request);
trackBroadcast(hash);
} catch (error) {
if (error instanceof BaseError) {
const revertError = error.walk(
(cause) => cause instanceof ContractFunctionRevertedError,
);
if (revertError instanceof ContractFunctionRevertedError) {
showKnownContractError(revertError.data?.errorName);
return;
}
}
showStableError("TRANSACTION_PREPARATION_FAILED");
}
示例只展示模拟阶段的错误提取。生产代码还应在钱包请求、广播和等待 receipt 的边界分别捕获错误,并把原始错误安全地发送到内部监控,而不是直接展示给用户。
account 就模拟依赖 msg.sender 的写操作。estimateGas 或模拟成功就保证交易一定成功。发现这道题有问题? 反馈此题