T14 · Web3 Frontend
交易在等待确认的这十几秒里,界面该显示什么?
- 练习的能力
- Builder
- 动手
- 给一次交易实现完整状态机:待签名、已广播、待确认、成功、失败、被替换。
- AI Lab
- 让 AI 枚举交易失败的错误码并给出人话文案,自己验证其中的措辞会不会误导用户。
一个现实问题
用户点了「兑换」。
钱包弹出来,他确认了。弹窗关掉,页面上出现一个转圈的图标,文案写着「处理中」。
然后是十几秒。对一个用惯了 App 的人来说,十几秒是很长的时间——长到他会怀疑是不是卡住了,长到他会切到别的应用再切回来,长到他会再点一次那个按钮。
而这十几秒里,你的界面其实什么都不知道:
- 用户到底签了没有?他可能在钱包里点了拒绝,而你的页面还在转圈。
- 交易广播出去了,但它进内存池了吗?会不会因为手续费太低一直卡着?
- 它进区块了吗?进了区块是不是就成功了?
- 用户在钱包里点了「加速」,你手上那个交易哈希还有效吗?
任何一个问题答错,界面就会显示一个和事实不符的状态。最贵的那个错误是:交易失败了,你的界面显示成功了。
这不是一个 UI 细节。Web2 的请求要么成功要么失败,几百毫秒内有答案;链上交易有六种终局、十几秒到几分钟的中间态,而且中间态还会倒退。用画 Web2 页面的方式画它,一定会错。
思想实验
把一笔交易想成一封挂号信,而不是一次 HTTP 请求。
你把信投进邮筒。从这一刻起:
- 你手里有一张回执单号。有了它,你才能查询。没有它,你什么都做不了。
- 信在分拣中心排队。付的邮资越高排得越靠前,邮资太低可能排上好几天。
- 信被装上某一辆车了。但车还在路上,可能出事故,可能被退回来。
- 车到站了。这时候你才知道结果——而结果有两种:送达,或者送达失败并退回。
最后一点是关键,也是最反直觉的一点:「信到站了」和「信送成了」是两回事。
链上的对应关系是这样的:交易被打包进区块,只说明矿工/验证者执行了它,不说明执行成功了。一笔在执行中报错的交易,照样会被打包、照样会扣光你付的手续费,只是它在回执里标记为失败。
这件事在 T1 已经说过一次,这里要说第二次,因为它是这一行最常见的 bug:
查一笔交易成没成功,看回执的
status,不是看它在不在区块里。
还有一件挂号信没有的事:你可以用同一个单号,把这封信换成另一封。 在链上,同一个 nonce 只会有一笔交易最终上链。用户在钱包里点「加速」或「取消」,实际是用同一个 nonce 发了一笔新的交易。原来那个哈希,从此永远查不到了。
你的界面如果还在等它,就会永远转圈。
你来决定
要给这十几秒设计界面。你怎么做?
观察结果
四个选项的差别,可以归结成一句话:前三个在画页面,第四个在建模型。
画页面的做法,每多一种异常就要加一个 if,加到第五个的时候没人能说清界面到底有几种可能。建模型的做法,异常是状态机里的一个节点,加进去就完了。
把一笔交易可能的终局摊开,一共六个:
| 状态 | 它意味着什么 | 界面该做什么 | 是终态吗 |
|---|---|---|---|
| 待签名 | 请求已发给钱包,等用户操作 | 提示去钱包确认,按钮禁用 | 否 |
| 已广播 | 拿到哈希,进了内存池 | 展示哈希与浏览器链接,允许加速 | 否 |
| 待确认 | 进了某个区块,确认数不够 | 展示确认进度 | 否 |
| 成功 | 回执 status 为成功,确认数够 | 展示结果,刷新数据 | 是 |
| 失败 | 回执 status 为失败 | 说清原因,给出下一步 | 是 |
| 被替换 | 同 nonce 的另一笔交易上链了 | 切到新哈希继续跟,或告知已取消 | 是 |
再加两条容易被忽略的规则:
用户拒签不是失败。 它应该安静地退回「空闲」,不弹错误提示。把拒签做成红色报错,会让用户觉得自己做错了事——而他只是改了主意。
「成功」也可能退回去。 链头会重组(T1 最后一个案例)。确认数不够就显示成功,重组之后这个成功是假的。所以「成功」在确认数达标之前都不是真正的终态。
建立模型
主路径是一条线:
- Idle 空闲
- AwaitingSignature 待签名
- Broadcast 已广播
- Pending 待确认
- Confirmed 成功
用类型把状态写出来。语言不重要,重要的是每个状态携带的数据不同——这正是用联合类型而不是布尔标志的原因:
type TxState =
| { kind: 'idle' }
| { kind: 'awaitingSignature'; intent: TxIntent }
| { kind: 'broadcast'; hash: string; nonce: number; submittedAt: number }
| { kind: 'pending'; hash: string; nonce: number; blockNumber: number; confirmations: number }
| { kind: 'confirmed'; hash: string; blockNumber: number; confirmations: number }
| { kind: 'failed'; hash?: string; reason: FailureReason }
| { kind: 'replaced'; originalHash: string; replacementHash: string; by: 'speedup' | 'cancel' | 'unknown' };
type FailureReason =
| 'userRejected' // 用户在钱包里拒绝了,严格说不算失败
| 'insufficientFunds' // 余额不够付手续费或转账金额
| 'reverted' // 上链了,但执行被回滚
| 'slippage' // 报价过期或滑点超限,属于 reverted 的一种
| 'nonceConflict' // nonce 被别的交易占了
| 'dropped' // 长时间没进区块,被内存池丢弃
| 'unknown';如果你用的是 isLoading、isSuccess、isError 三个布尔值,那么 isLoading && isSuccess 这种不可能的组合在类型上是合法的,而它一定会在某个分支里出现。联合类型让非法状态无法表示,这是这套设计最实际的收益。
转移表
每一条转移都是一个可以单独测试的用例:
| 从 | 触发 | 到 |
|---|---|---|
| 空闲 | 用户点击 | 待签名 |
| 待签名 | 拿到交易哈希 | 已广播 |
| 待签名 | 用户拒绝 | 空闲(不是失败) |
| 待签名 | 钱包报错 | 失败 |
| 已广播 | 查到交易在某区块里 | 待确认 |
| 已广播 | 超时且查不到该哈希 | 失败(dropped) |
| 已广播 | 同 nonce 的另一笔上链 | 被替换 |
| 待确认 | 回执成功且确认数达标 | 成功 |
| 待确认 | 回执失败 | 失败(reverted) |
| 待确认 | 父哈希不连续,发生重组 | 退回已广播 |
| 成功 | 确认数回退,发生重组 | 退回待确认 |
最后两行是这张表的价值所在。状态机必须允许倒退。 只能前进的状态机在重组面前会卡死在一个错误的终态上,而用户看到的那个「成功」永远不会被纠正。
三件容易写错的事
第一,跟踪要以 nonce 为主、哈希为辅。 哈希会变(加速、取消),nonce 不会。正确的检测方式是:如果这个地址的已确认 nonce 已经超过了你这笔交易的 nonce,但你的哈希查不到回执,那就是被替换了——去找那个 nonce 上真正上链的哈希,继续跟它。
第二,等待要能被中断和恢复。 用户会刷新页面、关标签页、切设备。待跟踪的交易要持久化到本地,页面加载时把未完成的交易恢复成跟踪中。只活在内存里的状态机,刷新一次就全丢了。
第三,按钮要在「待签名」就禁用,而不是在「已广播」才禁用。 这两者之间有一个几秒的窗口,用户在这个窗口里重复点击,会产生第二次签名请求。重复提交是这个窗口的必然产物,不是小概率事件。
错误翻译表
把底层错误映射成「一句人话 + 一个动作」。表里只有三列,但第三列最重要:
| 底层情况 | 给用户的话 | 用户能做什么 |
|---|---|---|
| 用户拒签 | 不提示,安静退回 | 重新点击 |
| 手续费余额不足 | 手续费不够,需要先补充主币 | 跳转到充值入口 |
| 执行被回滚 | 交易未能完成,手续费已消耗 | 重试,并展示可能原因 |
| 滑点超限 | 价格变化超过了你设置的范围 | 调高容忍度或重新报价 |
| 长时间未打包 | 网络拥堵,交易还在排队 | 加速或取消 |
| 被替换 | 这笔交易已被你的新交易替代 | 跳转去看新的那笔 |
两条写文案的硬规则:
- 不要在没拿到回执之前说「成功」。 「已提交」「已广播」「确认中」都可以,「成功」不行。
- 不要说「失败,请重试」然后什么也不解释。 用户重试的结果大概率是再失败一次,再扣一次手续费。
它叫什么
用有限的状态和明确的转移条件来描述一笔交易的完整生命周期。
它取代的是一堆散落在组件里的布尔标志。判断一个 Crypto 前端写得好不好,看它有没有这个东西就够了。
在交易确认之前,先按预期结果更新界面,让操作感觉是即时的。
它的必要配件是回滚:失败、被替换、被重组时要能把界面改回去,并告诉用户。没有回滚的乐观更新不是好体验,是善意的谎言。
把节点、钱包、合约抛出的原始错误,翻译成用户能理解的说明和可执行的下一步。
映射表应该是集中的一份,而不是散在各个 catch 里。集中之后它才能被 review、被测试、被复用。
链头回滚时,把已经显示为成功的交易退回未确认状态,并在必要时撤回已经做出的乐观更新。
前端的重组处理比后端简单得多——只要状态机允许倒退就行。真正复杂的重组处理在 T17。
用相同的 nonce、更高的手续费发一笔新交易,顶替原来那笔。钱包里的「加速」和「取消」都是它。
对前端的含义只有一句:原哈希会永远查不到回执,必须靠 nonce 才能发现真相。
交易所在区块之后又出了多少个区块。它是一个概率性的安全指标,不是布尔值。
确认数要几个,是业务决定,不是技术决定:展示一个头像换了就够 1 个,放行一笔大额提现可能要几十个。
动手
全程在测试网上做,钱包里只放水龙头领来的测试币。 这个 Lab 需要你故意制造失败和卡顿,不要在主网上练。
目标不是做出一个漂亮界面,是让六个状态全部被真实地走到过一次。
先写类型,再写组件。
把上面那个联合类型抄进你的项目,然后写一个纯函数的 reducer:输入当前状态和一个事件,输出新状态。
这一步的验收标准:reducer 里没有任何网络调用。它必须是纯的,这样你才能不联网地测遍所有转移。
接上顺利路径,走到「成功」。
发一笔简单的测试网转账。拿到哈希后进入已广播,轮询回执,拿到回执且 status 为成功、确认数达标后进入成功。
注意验收 status。 写一行断言:如果 status 不是成功却进了成功状态,直接抛错。这一行断言就是这一章的核心。
走到「失败」。
构造一笔必然被回滚的交易——最简单的做法是往一个合约的某个函数传一个必然不满足条件的参数,比如转出一个超过余额的数量。
这笔交易会上链,会扣手续费,回执 status 是失败。确认你的界面显示的是失败而不是成功。
如果它显示了成功,恭喜,你刚刚在自己的代码里复现了全行业最常见的那个 bug。
走到「被替换」。
把手续费设得远低于当前水平,发一笔交易,它会卡在内存池里。
记下哈希和 nonce。然后在钱包里对这笔交易点「加速」或「取消」——钱包会用同一个 nonce 发一笔新的。
观察你的界面:它应该检测到「这个地址的已确认 nonce 已经超过我这笔的 nonce,但我的哈希没有回执」,从而进入被替换状态,并把跟踪目标切到新哈希。
如果它还在转圈,说明你只跟了哈希没跟 nonce。
走到「待签名后返回空闲」。
发起一笔交易,在钱包弹窗里点拒绝。
界面应该安静地回到空闲,按钮重新可用,不弹红色错误。如果弹了,改掉——拒签不是错误。
制造一次倒退。
重组不好自己造,但你可以直接给 reducer 发一个「确认数回退」事件,验证状态从成功退回待确认,并且界面上原本的成功提示变成了「重新确认中」。
这一步是纯逻辑测试,几行代码就能做完,但它验证的是整个设计里最难得的那条性质:状态机允许倒退。
加一张错误映射表,并做一次刷新测试。
把上文那张表实现成一份集中的映射。然后:发一笔交易,在它还没确认的时候刷新页面。
交易应该被恢复成跟踪中,而不是消失。做不到,说明你的状态只活在内存里。
做完七步,你手上就有一台可以直接搬进任何项目的状态机。这是这个阶段最可复用的一份代码。
AI Lab
两步走,第二步是你真正要的:
第一步:
列出一笔链上交易从构造到确认可能出现的全部失败情形。
对每一种给出:触发条件、用户视角看到的现象、判断依据(具体看哪个字段)。
包括用户拒签、手续费不足、执行回滚、滑点超限、nonce 冲突、
交易被丢出内存池、交易被同 nonce 的新交易替换。
第二步:
为上面每一种写一条给普通用户看的中文提示,要求:
- 不超过 20 个字
- 说清发生了什么
- 给出一个用户现在就能做的动作
然后逐条自我审查:这条文案会不会让用户误以为交易已经成功,
或者误以为钱已经到账、已经扣掉、还能撤回?第二步里的自我审查,模型会做得不错,但它审出来的结果你必须自己再过一遍。文案的危险之处在于它读起来都很顺——「交易已完成」和「交易已提交」在语感上差别很小,在含义上差了一个 status 字段。
有一个几乎必然出现的偏差要提前知道:模型很容易漏掉「被替换」。 因为它在大多数文档和教程里都不存在,只在真实用户点了「加速」之后才出现。如果它漏了,直接问它「用户在钱包里点加速之后,我原来那个哈希会怎样」,看它答不答得上来。
AI 说完之后,你必须自己验证
- 它有没有把「交易已上链」等同于「交易成功」——这是最要命的一条,逐个文案检查
- 它写的文案里,有没有在还没拿到回执的阶段就出现「成功」「完成」「到账」这类词
- 它有没有把用户拒签当成一种错误,配了红色报错文案
- 它有没有覆盖「被替换」这个状态——多数模型会漏掉它
- 它给的错误码、错误字段名、判断条件,你在真实钱包和节点返回里各验证一个
- 每条文案后面有没有一个用户真的能执行的动作,还是只写了「请稍后重试」
- 手续费不足和余额不足被分开了吗——这两件事的解决办法完全不同
真实案例
最常见的一个 bug,几乎每个新项目都会犯一次。
现象:用户看到绿色的「兑换成功」,去钱包里一看,币没换到,手续费倒是扣了。回执里 status 是失败,但前端从来没读过这个字段——它在拿到哈希的那一刻就把界面切成了成功。
这个 bug 的隐蔽之处在于:顺利路径下它表现完全正常,只有在交易失败时才暴露,而开发时很少有人专门去构造一笔会失败的交易。
所以 Lab 的第三步是故意去造一笔。
用户嫌慢,在钱包里点了加速。交易很快就上链了,但页面还在转圈,而且会一直转下去。
原因:加速用的是同一个 nonce、新的哈希。前端一直在轮询旧哈希的回执,而那个哈希永远不会有回执。
更糟的版本是:前端在轮询若干次之后报了「交易失败」。用户的交易其实成功了,界面却告诉他失败了——他很可能会再发一笔。
一个交易记录列表,点完立刻插入一条「兑换成功」。交易失败了,这条记录还在。
用户下次打开页面,列表刷新,那条记录消失了——他会以为是产品丢了他的记录,而不是那笔交易本来就没成。
乐观更新如果没有回滚,它制造的不是错觉,是不信任。
移动端用户点击后跳转到钱包应用,确认,再切回浏览器。这一来一回,页面可能已经被系统回收并重新加载。
结果:交易其实发出去了,但页面上什么都没有,用户以为没成功,于是再发一次。两笔交易都会执行。
这正是「状态必须持久化」的直接理由:页面重新加载时,要能从本地恢复出「有一笔交易正在进行中」。
改一个变量
状态机一个字都不用改,只是每个中间状态停留的时间变短了。
但有一件事反而更重要了:出块越快,单个区块的分量越轻,链头重组的概率通常更高。「确认数」这个词的含义随之改变——同样是 12 个确认,在两条链上代表的安全程度可能差很远。
结论是:确认数不能写成一个全局常量,它是每条链每种业务各自的配置。
一次点击会变成一笔包含多个动作的交易。这带来两个变化。
好的变化:授权和操作可以合并成一步,用户少点一次,而且不会出现「授权成功但操作失败」的中间态。
麻烦的变化:整笔交易只有一个回执 status,但它内部可能有多个动作,需要从日志里去判断哪一步出了问题。你的错误映射要从「一笔交易一个错误」升级成「一笔交易一组结果」。T27 会展开。
用户不需要持有主币了,「手续费不足」这类错误从流程里消失。体验上是巨大的改进。
但状态机多了一个前置环节:你得先确认代付方愿意为这笔交易付钱。它可能拒绝,可能限额,可能在链上执行到一半失败。这是一个新的失败来源,要进你的错误映射表。
另外,交易的发起方可能不再是用户地址,你跟踪 nonce 的对象也要跟着换。
用户在一台不联网的设备上签名,再把签好的交易搬到联网设备广播。
状态机被劈成了两段:「待签名」和「已广播」之间出现了一个人工搬运的空档,这个空档可能是几分钟,也可能是几天。
这时候「交易已过期」会变成一种常见的失败:签的时候 nonce 是对的,搬过去的时候这个 nonce 已经被别的交易用掉了。时间一旦被拉长,原本可以忽略的竞态就会变成常态。
带走的问题
用户是谁?在这一章里,答案是「一个不知道区块链在干什么、只想知道自己那笔钱怎么样了的人」。他不需要看到十六进制,但他有权知道真实状态——包括失败。
谁在支付?交易失败了,手续费照样是用户付的。这条事实必须出现在你的失败文案里,否则用户会先怀疑你偷了钱,再去别处问。
谁承担风险?界面把失败显示成成功时,损失落在用户身上,而且是静默的——他会带着一个错误的认知继续操作。这正是状态机的价值:它把「我可能显示错」从一个运气问题变成了一个可以穷举测试的问题。
本章自测
因为执行失败的交易同样会被打包,同样扣手续费,只是回执里的 status 标记为失败。
判断成功必须同时满足两个条件:回执的 status 是成功,并且确认数达到了你设定的阈值。少任何一个,都不能显示「成功」。
这一条在 T1 讲过,在这里变成了一行代码:读 status。
前端在轮询旧哈希,而旧哈希永远不会有回执。要靠 nonce 来发现真相:
- 查询这个地址的已确认 nonce。
- 如果它已经超过了你这笔交易的 nonce,而你的哈希仍然没有回执,那就是被替换了。
- 去找那个 nonce 上真正上链的交易,把跟踪目标切过去,并告诉用户「这笔交易已被你的新交易替代」。
一句话记住:哈希会变,nonce 不会。跟踪以 nonce 为主。
不算。它应该安静地退回空闲状态,按钮重新可用,不弹任何红色提示。
原因不只是体验:把拒签做成报错,会让用户产生「我好像做错了什么」的心理负担,而拒签恰恰是我们希望用户保有的能力——尤其在看到一个看不懂的签名请求时。
不要惩罚用户的谨慎。
因为布尔值允许非法组合。isLoading 和 isSuccess 同时为 true 在类型上完全合法,而它一定会在某个分支里出现,然后变成一个没人能复现的 bug。
联合类型让非法状态无法被表示,同时强制每个分支处理所有情况。附带的好处是每个状态可以携带自己的数据——「已广播」有哈希,「空闲」没有,不需要到处写可选字段。
没有标准答案,检查这五件事:
- 六个状态是不是都在?特别是被替换——漏掉它的设计占大多数。
- 有没有允许倒退的转移?重组时「成功」必须能退回「待确认」。
- 用户拒签走向哪里?走向失败就是错的。
- 页面刷新之后,进行中的交易还在吗?只活在内存里就是不及格。
- 每一个失败分支,用户都知道下一步该做什么吗?
第 2 条是这五条里最少被想到、也最能区分水平的一条。
一句话带走
Crypto 前端的核心是一台交易状态机,不是一堆页面。