T3 · 一次交易从代码到链上
从构造到确认,代码到底要做哪几件事?
- 练习的能力
- BuilderOnchain Literacy
- 动手
- 用代码在测试网发一笔交易,故意把 Gas 设得过低让它卡住,再用同 nonce 替换掉它。
- AI Lab
- 让 AI 列出交易的十种失败情形与对应的报错,自己在测试网复现其中三种。
一个现实问题
你给产品上了一个提现功能。用户填金额,点确认,按钮转圈。
上线第一周,客服转来三类投诉:
- **「钱扣了,但对方没收到。」**你去区块浏览器一查,交易确实在链上,页面上有个红色的小图标,手续费扣了,金额一分没动。
- **「点了半小时,一直在处理中。」**你查日志,交易发出去了,哈希也有,但链上查不到这笔交易——它不在任何一个区块里,也没有报错。
- **「我怎么提了两次?」**用户等急了,又点了一次。两笔都成功了。
三件事的共同点是:你的代码只知道「我把请求发出去了」,不知道后面发生了什么。
一笔交易从你的进程里出发,到用户真的能说「到账了」,中间到底经过了哪几步?每一步各自会怎么坏?
思想实验
把一笔交易想成一封需要付邮费的挂号信,寄给一个按规矩办事到有些死板的邮局。
规矩有四条:
**第一,每封信必须写一个编号,从 0 开始,一封一个,不能跳号。**邮局严格按编号投递。如果 5 号还没处理,你寄出去的 6 号就只能在分拣间躺着——它不会报错,就是不动。
**第二,邮费由你自己填,填多少由你决定。**分拣员每次只挑当前出价最高的一批信来处理。填低了,信不会被退回来,它会一直躺着,直到某一刻路上没人了,才轮到它。
第三,同一个编号可以寄第二封。新的那封只要邮费明显更高,就会把旧的顶掉。这是取消和加速的唯一手段——你没有办法把一封已经交出去的信要回来,只能用同号的新信覆盖它。
**第四,最要命的一条:邮局收到信、按规矩投递了,不代表收件人签收成功。**信送到了,对方拒收,邮费照收。从邮局的账本上看,这封信「已投递」;从你的业务上看,这件事没成。
现在把这四条规矩和开头三类投诉对一下:
- 「钱扣了没到账」= 第四条,信送到了但被拒收。
- 「一直在处理中」= 第一条或第二条,跳号了,或者邮费太低。
- 「提了两次」= 用户重试时你用了新编号,于是那是两封独立的信,两封都投递了。
三个问题不是三个 bug,是同一个模型缺失的三种表现。
你来决定
回到那个提现功能。界面上什么时候可以显示「提现成功」?
观察结果
四个选项其实是在同一条流水线上的四个不同位置按下暂停键。
| 你在哪一步宣布成功 | 你实际知道的事 | 你还不知道的事 |
|---|---|---|
| 签名完成 | 用户同意了这笔交易 | 它有没有发出去 |
| 拿到哈希 | 某个节点收下了它 | 它会不会被打包、会不会被替换、会不会被丢弃 |
| 进了区块 | 它被执行过 | 它是成功还是回滚 |
| 回执成功且已确认 | 它成功了,且短期内不会被翻掉 | 极端情况下的深度重组 |
结论是:这条流水线有五步,而不是一步。每一步都有自己独立的失败模式,而且这些失败模式的表现形式完全不一样——有的会抛异常,有的会返回错误码,有的什么都不返回,就是不动。
最危险的是最后这一类。抛异常的你会处理,静默卡住的你不会——直到客服找上门。
建立模型
五步流水线:
- 构造 Construct
- 签名 Sign
- 广播 Broadcast
- 执行 Execute
- 确认 Confirm
每一步要做什么、会怎么坏:
| 步骤 | 你的代码做什么 | 典型失败 | 表现形式 |
|---|---|---|---|
| 构造 | 填 to、value、data、nonce、gas 上限、费用上限、链编号 | 编号取错、金额单位搞错、链编号填错 | 下一步立刻报错,或者更糟:在错误的链上成功 |
| 签名 | 用私钥对交易内容签名,得到一串可以广播的字节 | 用错了密钥、签的是另一条链的格式 | 广播时被拒,或验签恢复出别的地址 |
| 广播 | 把那串字节交给一个节点 | 费用过低、编号已被占用、编号跳号、基础费不足 | 有的立刻报错,有的静默接受然后永远不动 |
| 执行 | 节点打包后执行,产生回执 | 合约回滚、gas 上限耗尽、状态变了导致条件不满足 | 交易在区块里,回执状态为失败,手续费照扣 |
| 确认 | 等待后续区块堆叠 | 重组、被同号交易替换、内存池清理后丢弃 | 一笔已经查得到的交易,过一会儿查不到了 |
有四件事值得单独拿出来说,它们是这一章真正的密度所在。
一、编号有两种读法,用错一种就会卡住。
查「这个地址下一个可用编号」时,你可以问节点「按已上链的算」还是「按包括内存池里待处理的算」。连续发多笔时必须用后者,否则第二笔会用和第一笔相同的编号,直接被拒。但用后者也有陷阱:如果第一笔永远不会上链,后面所有笔都会被它堵死。
二、费用不够不会报错,只会不动。
这是最反直觉的一点。传统系统里,请求要么成功要么失败;这里有第三种状态:被接受了,但无限期挂起。你的代码拿到了哈希,一切看起来正常,实际上什么都没发生。
三、交易失败照样扣费,而且扣的是已经执行掉的那部分。
从链的角度看这很合理:节点确实花了算力去执行它。但从用户角度看,这是「我付了钱,什么也没得到」。你的错误文案必须诚实地说明这一点,否则用户会认为是你吞了钱。
四、取消一笔交易的唯一方法,是用同一个编号发一笔新的。
没有「撤回」这个操作。你能做的只有:用相同编号、更高费用,发一笔把钱转给自己、金额为零的交易。哪一笔先被打包,另一笔就自动作废。注意新交易的哈希和旧的不同——你的系统必须同时追踪这两个哈希,否则会出现「明明成功了,系统显示失败」。
它叫什么
同一个地址发出的交易的序号,从 0 开始严格递增,不能跳号。
它有两个作用:防止同一笔交易被重放;确定同一个地址多笔交易之间的执行顺序。
这个词在密码学里指「一次性随机数」(T2 讲过),在这里指「计数器」。同名不同物,别混。
Gas 上限是你允许这笔交易最多消耗多少计算量;费用上限是你愿意为每单位计算量出多少钱。
两者的失败模式完全不同:**Gas 上限不够,交易会执行到一半失败,手续费照扣;费用上限不够,交易根本不会被执行,会一直挂着。**把这两个搞混,排查方向就全错了。
签名后可以直接广播的那串字节。
它的哈希在广播之前就确定了,所以「拿到哈希」不代表「链知道这笔交易」。理解这一点能省掉你很多次排查。
节点本地保存的、尚未被打包的交易集合。
关键词是本地:它不是链的一部分,每个节点的内存池都不一样,没有任何东西保证你的交易在所有节点上都存在,也没有任何东西保证它会一直待着。T4 会讲这个差异带来的麻烦。
用相同 nonce、更高费用发出的新交易,会顶掉内存池里的旧交易。
加速和取消是同一个机制的两种用法。替换有一个最低加价幅度,加得不够多会被节点拒绝,报错措辞通常包含「替换交易出价过低」。
确认数是这笔交易之上又堆了多少个区块;最终性是这笔交易在协议层面被标记为不可回滚。
**两者不是一回事。**确认数是一个概率判断,最终性是一个协议承诺。查询时可以用明确的区块标签来区分「最新的」和「已最终确定的」,T4 会展开。
动手
**全程在测试网,测试币没有任何价值,不涉及任何真实资产。**不要用你放着真钱的钱包做这个实验,新建一个专门的测试钱包。
先把两种 nonce 读法的差别看清楚。
curl -s -X POST <你的测试网-RPC-地址> \
-H 'content-type: application/json' \
-d '{"jsonrpc":"2.0","id":1,"method":"eth_getTransactionCount",
"params":["<你的地址>","latest"]}'
curl -s -X POST <你的测试网-RPC-地址> \
-H 'content-type: application/json' \
-d '{"jsonrpc":"2.0","id":1,"method":"eth_getTransactionCount",
"params":["<你的地址>","pending"]}'现在两个应该相等。做完后面几步再回来查一次,你会看到它们分叉。
看一眼当前的费用水平。
curl -s -X POST <你的测试网-RPC-地址> \
-H 'content-type: application/json' \
-d '{"jsonrpc":"2.0","id":1,"method":"eth_gasPrice","params":[]}'返回的是十六进制的 wei。转成十进制,再除以 10 的 9 次方得到 gwei。记下这个数,下一步要用一个远低于它的值。
发一笔正常的交易,确认链路是通的。
用钱包给另一个自己的测试地址转一个很小的金额。记下哈希,然后查:
curl -s -X POST <你的测试网-RPC-地址> \
-H 'content-type: application/json' \
-d '{"jsonrpc":"2.0","id":1,"method":"eth_getTransactionByHash","params":["<哈希>"]}'留意返回里的 blockNumber 字段。**它是 null,就说明这笔交易还在内存池里。**这个字段是你判断「已广播」和「已打包」的唯一依据。
故意让一笔交易卡住。
在钱包里打开高级设置(不同钱包叫法不同,找「自定义 nonce」或「高级 Gas 控制」),发一笔转账,把费用上限手动改成第 2 步查到的值的十分之一,nonce 保持默认。
发出去。观察:钱包给了你哈希,没有报错,但 eth_getTransactionByHash 里的 blockNumber 一直是 null。
**这就是那类最难排查的失败:一切正常,就是不动。**去区块浏览器搜这个哈希,多半显示「pending」或者干脆搜不到。
再发一笔,看它怎么被堵住。
保持上一笔卡着,用默认费用再发一笔普通转账。它会拿到下一个 nonce。
观察:这一笔明明费用充足,却同样不会被打包。**因为前面那个 nonce 没处理,后面的全部排队。**现在回去查第 1 步那两个 nonce,它们已经差了 2。
一个卡住的交易堵死整个地址的出金队列——这是资金系统最常见的线上事故之一。
用同 nonce 替换掉它。
回到钱包,找到第 4 步那笔卡住的交易,选「加速」或「取消」。钱包会用相同的 nonce、更高的费用重新构造一笔。
如果你想手动做:新交易的 nonce 填成卡住那笔的 nonce,费用上限设成当前水平的两倍以上(加价不够会被拒,错误信息里会明确写替换出价过低)。
替换成功后:
- 旧哈希查
eth_getTransactionByHash,返回 null——这笔交易从此不存在了。 - 新哈希能查到,
blockNumber不再是 null。 - 被堵住的第二笔也跟着一起上链了。
最后确认一笔交易到底成没成。
curl -s -X POST <你的测试网-RPC-地址> \
-H 'content-type: application/json' \
-d '{"jsonrpc":"2.0","id":1,"method":"eth_getTransactionReceipt","params":["<新哈希>"]}'看两个字段:status 为 0x1 是成功,0x0 是失败;gasUsed 是实际扣掉的量。
**把这一步写进你的代码。**不查回执状态就宣布成功,等于把开头第一类投诉留给客服。
AI Lab
分三步问。
第一步:
列出一笔以太坊交易从构造到确认可能出现的十种失败情形。
每种给出:发生在哪一步、节点返回的典型错误信息原文、
用户侧看到的现象、手续费扣不扣、正确的处理方式。
用表格。
第二步:
这十种里,哪几种不会抛出任何错误,只会表现为「一直没动」?
对这几种,说明我的后端该用什么方法检测出来,检测周期设多久。
第三步:
给我一个在测试网上复现第 2、5、8 种的具体操作步骤,
只用 JSON-RPC 和一个普通钱包,不要用任何 SDK。第二步是这个 Lab 的价值所在。会抛异常的失败你本来就会处理,不抛异常的那几种才是线上事故的来源。
第三步用来抓它的幻觉:真去复现一遍,你会发现某几条的错误信息和它写的对不上。不同的节点客户端措辞不一样,而模型是在混杂语料里学的,它给的往往是几家措辞的混合体。
**这正是你需要亲自跑一遍的原因:你的错误映射表必须基于你实际用的那个节点,不能基于模型的记忆。**T14 会把这张表变成给用户看的文案。
AI 说完之后,你必须自己验证
- 它列的每一条报错字符串,你能在真实节点上原样复现,而不是它按印象写的
- 它有没有区分「交易出价过低」和「替换交易出价过低」,这是两个不同的错误
- 它有没有说失败的交易不扣手续费,这是错的
- 它有没有把 gas 上限不足和费用上限不足的后果搞反
- 它给的 SDK 方法名和参数顺序,你能在官方文档里查到同名的方法
- 它写的示例里,wei、gwei 与十六进制之间的换算没有算错
- 它有没有把「交易在区块里」当成「交易成功」
真实案例
一个服务在费用飙升时发出了一笔费用上限偏低的提现交易。它没报错,只是没动。
之后所有提现交易依次拿到更大的 nonce,全部排在它后面,全部不动。几个小时内积压了上千笔,客服被淹没,而监控没有任何告警——因为没有一个请求失败。
修复只需要一个动作:用同 nonce 重发一笔高费用交易。难的是意识到要这么做。
这之后该做的工程改造是:给每笔交易设置「多久没上链就升价重发」的超时,以及给 pending 与 latest 的 nonce 差值加告警。
有人发出一笔交易,付出的手续费比转账金额本身高了三个数量级,价值相当于当时的一大笔钱。
原因据推测是构造交易时把两个字段填反了:本该填金额的地方填了费用参数,本该填费用的地方填了金额。
这个案例的教训不是「要小心」,是构造交易的代码必须有单位断言和上限校验。人一定会填错,代码要在广播前拦住。
用户在钱包里对一笔卡住的交易点了加速。钱包用同 nonce、更高费用重发,交易成功上链。
但业务系统记录的是旧哈希。它一直在轮询那个哈希,永远查不到,超时后把这笔提现标记为失败并解冻了余额——而链上那笔钱已经出去了。
正确做法:不要只盯一个哈希,要同时监控这个地址在这个 nonce 上的最终结果。T14 会把「被替换」作为状态机里的一个独立状态处理。
一笔调用合约的交易执行时回滚了。回执状态是失败,手续费扣了。
某些钱包和前端在这种情况下仍然展示绿色的成功提示,因为它们只检查了「交易是否上链」。用户看到成功,去对方那里查,什么都没有。
这类 bug 出现的频率高到可以作为一个行业常识:status 字段必须显式检查,不能默认。
改一个变量
两个进程会读到同一个 nonce,构造出两笔不同的交易用同一个编号。先到的被接受,后到的被当作替换交易——要么被拒(加价不够),要么把前一笔顶掉。
结果是随机丢单,而且很难复现。
解决方案不是加锁那么简单:必须有一个单一的 nonce 分配者,并且这个分配要和「交易已提交」的持久化写在同一个事务里。T15 会把这套设计完整讲一遍。
签名的内容已经定死,费用上限改不了。这笔交易会一直等到网络降温才可能被打包,可能是几分钟,也可能是几天。
这解释了为什么生产系统不能「签完就不管了」:**必须有一个后台任务监控待处理交易,超时就用同 nonce 升价重发。**这个循环本身要幂等,否则会重发出一堆互相替换的交易。
那是两笔完全独立的交易,两笔都会执行,钱出去两次。
这就是开头第三类投诉。防法是在业务层做幂等:同一个提现请求永远对应同一个 nonce 和同一份交易内容,重试是重发同一笔已签名的字节,而不是重新构造一笔。
没有递增的 nonce,取而代之的是一个近期区块哈希。它有有效期,过期之后交易不是卡住,而是彻底失效,需要重新构造和签名。
这带来一组完全不同的工程问题:你要处理的不是「卡住」,是「过期后不知道到底发没发出去」。而且 Solana 的交易同样有失败状态,同样要检查回执里的错误字段。T6 会讲它的账户模型,T14 会讲两套状态机的差异。
带走的问题
手续费永远由发起交易的那个地址支付,包括失败的交易。这决定了一个产品设计问题:是让用户自己付,还是你代付。代付意味着你要维护一个热钱包、一套 nonce 管理和一条被滥用时的风控线——这些成本都在这一章里。
交易卡住时,用户承担等待,你承担客服成本;交易失败时,用户承担手续费;系统对不上账时,损失通常由你承担。三种失败模式对应三种不同的责任归属,所以它们必须在代码里被区分开,而不是统统抛一个「交易失败」。
如果让一个 AI Agent 来发交易,它最可能犯的错是在超时后重新构造并签名一笔新交易,而不是重发原来那笔——于是钱出去两次。模型不理解 nonce 的幂等语义,所以重试逻辑必须写死在你的代码里,不能交给它临场判断。
本章自测
不是。哈希是从交易内容算出来的,在广播之前就已经确定。它是这笔交易的名字,不是链给你的回执。
判断它有没有被打包,要看 eth_getTransactionByHash 返回的 blockNumber 是不是 null;判断它成没成功,要看回执里的 status。
完全不一样,这是最值得记住的一组区分。
Gas 上限不够:交易会被打包、会被执行、执行到一半耗尽后回滚,手续费照扣。 费用上限不够:交易根本不会被打包,会一直挂在内存池里,一分钱不扣,也一直不动。
一个是「花了钱没办成」,一个是「没花钱也没办成」。排查方向和给用户的文案都不同。
没有取消这个操作。唯一的方法是用相同的 nonce 发一笔新交易,费用足够高,把旧的顶掉。
最常见的做法是发一笔转给自己、金额为零的交易。注意新交易的哈希和旧的不同,你的系统要同时追踪两个哈希,或者干脆改成按「地址加 nonce」来追踪最终结果。
因为同一个地址的交易必须按 nonce 严格顺序执行,不能跳号。
nonce 为 5 的交易没被处理,nonce 为 6、7、8 的交易即使费用再高也不会被执行——它们不是「优先级低」,是在协议层面还没轮到。
后果是:一笔卡住的交易会让整个地址的出金队列停摆。这也是为什么高并发的资金系统往往会用多个发送地址分散风险。
没有标准答案,检查这几件事:
- 状态是否至少包含:待签名、已签名、已广播、已打包、已确认、执行失败、被替换、已超时重发。把「已广播」和「成功」合并的方案不合格。
- nonce 是否由单一来源分配,并且和业务记录写在同一个事务里。
- 重试时是重发同一份已签名的字节,还是重新构造?只有前者是幂等的。
- 有没有一个后台任务处理「超时未上链」,并且它自己不会发出一堆互相替换的交易。
- 有没有监控 pending 与 latest 两种 nonce 的差值。
- 判定成功时,是否显式检查了回执的 status 字段。
- 用户在钱包里自行加速导致哈希变化时,系统会不会误判为失败。
能写出第 3、4、7 条,说明你已经不是在写 demo 了。
一句话带走
构造、签名、广播、执行、确认,每一步都有自己的失败模式。