Crypto OS
Technical Crypto OS第一阶段 · Blockchain Developer Mental Model

T3 · 一次交易从代码到链上

从构造到确认,代码到底要做哪几件事?

练习的能力
BuilderOnchain Literacy
动手
用代码在测试网发一笔交易,故意把 Gas 设得过低让它卡住,再用同 nonce 替换掉它。
AI Lab
让 AI 列出交易的十种失败情形与对应的报错,自己在测试网复现其中三种。

一个现实问题

你给产品上了一个提现功能。用户填金额,点确认,按钮转圈。

上线第一周,客服转来三类投诉:

  • **「钱扣了,但对方没收到。」**你去区块浏览器一查,交易确实在链上,页面上有个红色的小图标,手续费扣了,金额一分没动。
  • **「点了半小时,一直在处理中。」**你查日志,交易发出去了,哈希也有,但链上查不到这笔交易——它不在任何一个区块里,也没有报错。
  • **「我怎么提了两次?」**用户等急了,又点了一次。两笔都成功了。

三件事的共同点是:你的代码只知道「我把请求发出去了」,不知道后面发生了什么。

一笔交易从你的进程里出发,到用户真的能说「到账了」,中间到底经过了哪几步?每一步各自会怎么坏?

思想实验

把一笔交易想成一封需要付邮费的挂号信,寄给一个按规矩办事到有些死板的邮局。

规矩有四条:

**第一,每封信必须写一个编号,从 0 开始,一封一个,不能跳号。**邮局严格按编号投递。如果 5 号还没处理,你寄出去的 6 号就只能在分拣间躺着——它不会报错,就是不动。

**第二,邮费由你自己填,填多少由你决定。**分拣员每次只挑当前出价最高的一批信来处理。填低了,信不会被退回来,它会一直躺着,直到某一刻路上没人了,才轮到它。

第三,同一个编号可以寄第二封。新的那封只要邮费明显更高,就会把旧的顶掉。这是取消和加速的唯一手段——你没有办法把一封已经交出去的信要回来,只能用同号的新信覆盖它。

**第四,最要命的一条:邮局收到信、按规矩投递了,不代表收件人签收成功。**信送到了,对方拒收,邮费照收。从邮局的账本上看,这封信「已投递」;从你的业务上看,这件事没成。

现在把这四条规矩和开头三类投诉对一下:

  • 「钱扣了没到账」= 第四条,信送到了但被拒收。
  • 「一直在处理中」= 第一条或第二条,跳号了,或者邮费太低。
  • 「提了两次」= 用户重试时你用了新编号,于是那是两封独立的信,两封都投递了。

三个问题不是三个 bug,是同一个模型缺失的三种表现。

你来决定

回到那个提现功能。界面上什么时候可以显示「提现成功」?

观察结果

四个选项其实是在同一条流水线上的四个不同位置按下暂停键。

你在哪一步宣布成功你实际知道的事你还不知道的事
签名完成用户同意了这笔交易它有没有发出去
拿到哈希某个节点收下了它它会不会被打包、会不会被替换、会不会被丢弃
进了区块它被执行过它是成功还是回滚
回执成功且已确认它成功了,且短期内不会被翻掉极端情况下的深度重组

结论是:这条流水线有五步,而不是一步。每一步都有自己独立的失败模式,而且这些失败模式的表现形式完全不一样——有的会抛异常,有的会返回错误码,有的什么都不返回,就是不动。

最危险的是最后这一类。抛异常的你会处理,静默卡住的你不会——直到客服找上门。

建立模型

五步流水线:

  1. 构造 Construct
  2. 签名 Sign
  3. 广播 Broadcast
  4. 执行 Execute
  5. 确认 Confirm
前三步在你的进程里,第四步在节点里,第五步在时间里。三个地方的错误需要三套不同的处理方式。

每一步要做什么、会怎么坏:

步骤你的代码做什么典型失败表现形式
构造填 to、value、data、nonce、gas 上限、费用上限、链编号编号取错、金额单位搞错、链编号填错下一步立刻报错,或者更糟:在错误的链上成功
签名用私钥对交易内容签名,得到一串可以广播的字节用错了密钥、签的是另一条链的格式广播时被拒,或验签恢复出别的地址
广播把那串字节交给一个节点费用过低、编号已被占用、编号跳号、基础费不足有的立刻报错,有的静默接受然后永远不动
执行节点打包后执行,产生回执合约回滚、gas 上限耗尽、状态变了导致条件不满足交易在区块里,回执状态为失败,手续费照扣
确认等待后续区块堆叠重组、被同号交易替换、内存池清理后丢弃一笔已经查得到的交易,过一会儿查不到了

有四件事值得单独拿出来说,它们是这一章真正的密度所在。

一、编号有两种读法,用错一种就会卡住。

查「这个地址下一个可用编号」时,你可以问节点「按已上链的算」还是「按包括内存池里待处理的算」。连续发多笔时必须用后者,否则第二笔会用和第一笔相同的编号,直接被拒。但用后者也有陷阱:如果第一笔永远不会上链,后面所有笔都会被它堵死。

二、费用不够不会报错,只会不动。

这是最反直觉的一点。传统系统里,请求要么成功要么失败;这里有第三种状态:被接受了,但无限期挂起。你的代码拿到了哈希,一切看起来正常,实际上什么都没发生。

三、交易失败照样扣费,而且扣的是已经执行掉的那部分。

从链的角度看这很合理:节点确实花了算力去执行它。但从用户角度看,这是「我付了钱,什么也没得到」。你的错误文案必须诚实地说明这一点,否则用户会认为是你吞了钱。

四、取消一笔交易的唯一方法,是用同一个编号发一笔新的。

没有「撤回」这个操作。你能做的只有:用相同编号、更高费用,发一笔把钱转给自己、金额为零的交易。哪一笔先被打包,另一笔就自动作废。注意新交易的哈希和旧的不同——你的系统必须同时追踪这两个哈希,否则会出现「明明成功了,系统显示失败」。

它叫什么

Nonce随机数 / 序号

同一个地址发出的交易的序号,从 0 开始严格递增,不能跳号。

它有两个作用:防止同一笔交易被重放;确定同一个地址多笔交易之间的执行顺序。

这个词在密码学里指「一次性随机数」(T2 讲过),在这里指「计数器」。同名不同物,别混。

Gas Limit / Fee CapGas 上限 / 费用上限

Gas 上限是你允许这笔交易最多消耗多少计算量;费用上限是你愿意为每单位计算量出多少钱。

两者的失败模式完全不同:**Gas 上限不够,交易会执行到一半失败,手续费照扣;费用上限不够,交易根本不会被执行,会一直挂着。**把这两个搞混,排查方向就全错了。

Raw Transaction原始交易

签名后可以直接广播的那串字节。

它的哈希在广播之前就确定了,所以「拿到哈希」不代表「链知道这笔交易」。理解这一点能省掉你很多次排查。

Mempool内存池

节点本地保存的、尚未被打包的交易集合。

关键词是本地:它不是链的一部分,每个节点的内存池都不一样,没有任何东西保证你的交易在所有节点上都存在,也没有任何东西保证它会一直待着。T4 会讲这个差异带来的麻烦。

Replacement替换交易

用相同 nonce、更高费用发出的新交易,会顶掉内存池里的旧交易。

加速和取消是同一个机制的两种用法。替换有一个最低加价幅度,加得不够多会被节点拒绝,报错措辞通常包含「替换交易出价过低」。

Confirmation / Finality确认数 / 最终性

确认数是这笔交易之上又堆了多少个区块;最终性是这笔交易在协议层面被标记为不可回滚。

**两者不是一回事。**确认数是一个概率判断,最终性是一个协议承诺。查询时可以用明确的区块标签来区分「最新的」和「已最终确定的」,T4 会展开。

动手

动手在测试网发一笔交易,故意让它卡住,再用同 nonce 替换掉它任意测试网 + curl + 一个支持手动设置 nonce 的钱包0 元,测试币从水龙头领

**全程在测试网,测试币没有任何价值,不涉及任何真实资产。**不要用你放着真钱的钱包做这个实验,新建一个专门的测试钱包。

先把两种 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":["<新哈希>"]}'

看两个字段:status0x1 是成功,0x0 是失败;gasUsed 是实际扣掉的量。

**把这一步写进你的代码。**不查回执状态就宣布成功,等于把开头第一类投诉留给客服。

AI Lab

AI Lab让 AI 列出交易的十种失败情形与对应报错,你在测试网复现其中三种Level 2 · AI Copilot

分三步问。

第一步:
列出一笔以太坊交易从构造到确认可能出现的十种失败情形。
每种给出:发生在哪一步、节点返回的典型错误信息原文、
用户侧看到的现象、手续费扣不扣、正确的处理方式。
用表格。

第二步:
这十种里,哪几种不会抛出任何错误,只会表现为「一直没动」?
对这几种,说明我的后端该用什么方法检测出来,检测周期设多久。

第三步:
给我一个在测试网上复现第 2、5、8 种的具体操作步骤,
只用 JSON-RPC 和一个普通钱包,不要用任何 SDK。

第二步是这个 Lab 的价值所在。会抛异常的失败你本来就会处理,不抛异常的那几种才是线上事故的来源。

第三步用来抓它的幻觉:真去复现一遍,你会发现某几条的错误信息和它写的对不上。不同的节点客户端措辞不一样,而模型是在混杂语料里学的,它给的往往是几家措辞的混合体。

**这正是你需要亲自跑一遍的原因:你的错误映射表必须基于你实际用的那个节点,不能基于模型的记忆。**T14 会把这张表变成给用户看的文案。

AI 说完之后,你必须自己验证

  • 它列的每一条报错字符串,你能在真实节点上原样复现,而不是它按印象写的
  • 它有没有区分「交易出价过低」和「替换交易出价过低」,这是两个不同的错误
  • 它有没有说失败的交易不扣手续费,这是错的
  • 它有没有把 gas 上限不足和费用上限不足的后果搞反
  • 它给的 SDK 方法名和参数顺序,你能在官方文档里查到同名的方法
  • 它写的示例里,wei、gwei 与十六进制之间的换算没有算错
  • 它有没有把「交易在区块里」当成「交易成功」

真实案例

一个卡住的 nonce,堵死整条出金队列

一个服务在费用飙升时发出了一笔费用上限偏低的提现交易。它没报错,只是没动。

之后所有提现交易依次拿到更大的 nonce,全部排在它后面,全部不动。几个小时内积压了上千笔,客服被淹没,而监控没有任何告警——因为没有一个请求失败

修复只需要一个动作:用同 nonce 重发一笔高费用交易。难的是意识到要这么做。

这之后该做的工程改造是:给每笔交易设置「多久没上链就升价重发」的超时,以及给 pending 与 latest 的 nonce 差值加告警。

手续费比转账金额高出上千倍2019 年

有人发出一笔交易,付出的手续费比转账金额本身高了三个数量级,价值相当于当时的一大笔钱。

原因据推测是构造交易时把两个字段填反了:本该填金额的地方填了费用参数,本该填费用的地方填了金额。

这个案例的教训不是「要小心」,是构造交易的代码必须有单位断言和上限校验。人一定会填错,代码要在广播前拦住。

用户自己点了「加速」,系统就此对不上账

用户在钱包里对一笔卡住的交易点了加速。钱包用同 nonce、更高费用重发,交易成功上链。

但业务系统记录的是旧哈希。它一直在轮询那个哈希,永远查不到,超时后把这笔提现标记为失败并解冻了余额——而链上那笔钱已经出去了。

正确做法:不要只盯一个哈希,要同时监控这个地址在这个 nonce 上的最终结果。T14 会把「被替换」作为状态机里的一个独立状态处理。

交易失败,但钱包和前端都显示成功

一笔调用合约的交易执行时回滚了。回执状态是失败,手续费扣了。

某些钱包和前端在这种情况下仍然展示绿色的成功提示,因为它们只检查了「交易是否上链」。用户看到成功,去对方那里查,什么都没有。

这类 bug 出现的频率高到可以作为一个行业常识:status 字段必须显式检查,不能默认。

改一个变量

如果你的服务有两个进程同时给同一个地址发交易

两个进程会读到同一个 nonce,构造出两笔不同的交易用同一个编号。先到的被接受,后到的被当作替换交易——要么被拒(加价不够),要么把前一笔顶掉。

结果是随机丢单,而且很难复现。

解决方案不是加锁那么简单:必须有一个单一的 nonce 分配者,并且这个分配要和「交易已提交」的持久化写在同一个事务里。T15 会把这套设计完整讲一遍。

如果你签名之后,网络基础费翻了十倍

签名的内容已经定死,费用上限改不了。这笔交易会一直等到网络降温才可能被打包,可能是几分钟,也可能是几天。

这解释了为什么生产系统不能「签完就不管了」:**必须有一个后台任务监控待处理交易,超时就用同 nonce 升价重发。**这个循环本身要幂等,否则会重发出一堆互相替换的交易。

如果用户重试时你给了新的 nonce

那是两笔完全独立的交易,两笔都会执行,钱出去两次。

这就是开头第三类投诉。防法是在业务层做幂等:同一个提现请求永远对应同一个 nonce 和同一份交易内容,重试是重发同一笔已签名的字节,而不是重新构造一笔。

如果换成 Solana

没有递增的 nonce,取而代之的是一个近期区块哈希。它有有效期,过期之后交易不是卡住,而是彻底失效,需要重新构造和签名。

这带来一组完全不同的工程问题:你要处理的不是「卡住」,是「过期后不知道到底发没发出去」。而且 Solana 的交易同样有失败状态,同样要检查回执里的错误字段。T6 会讲它的账户模型,T14 会讲两套状态机的差异。

带走的问题

5
谁在支付?

手续费永远由发起交易的那个地址支付,包括失败的交易。这决定了一个产品设计问题:是让用户自己付,还是你代付。代付意味着你要维护一个热钱包、一套 nonce 管理和一条被滥用时的风控线——这些成本都在这一章里。

9
谁承担风险?

交易卡住时,用户承担等待,你承担客服成本;交易失败时,用户承担手续费;系统对不上账时,损失通常由你承担。三种失败模式对应三种不同的责任归属,所以它们必须在代码里被区分开,而不是统统抛一个「交易失败」。

17
AI 错误时谁承担损失?

如果让一个 AI Agent 来发交易,它最可能犯的错是在超时后重新构造并签名一笔新交易,而不是重发原来那笔——于是钱出去两次。模型不理解 nonce 的幂等语义,所以重试逻辑必须写死在你的代码里,不能交给它临场判断。

本章自测

一句话带走

构造、签名、广播、执行、确认,每一步都有自己的失败模式。

做完这一章的动手环节了?勾上它查看全部进度

本页目录