iT邦幫忙

2026 iThome 鐵人賽

DAY 11
0
Software Development

Web3 支付工程筆記:企業級穩定幣結算系統與多鏈架構實戰系列 第 11

Day 11 | Transaction Replacement:卡鏈救援與手續費加速機制

  • 分享至 

  • xImage
  •  

本日程式碼:repo tag day-11

昨天最後留下的問題是:一筆交易送出去、沒想到 RPC 呼叫逾時,我們不知道節點有沒有接收到它,所以那個 nonce 只能當成用掉;結果序列上留了一個沒交代的 nonce,整個錢包從此停止發號。昨天能做的只有等,等 Sync 看到鏈上走過那一個 nonce,或是等時間到 StuckAfter 之後把 intent 送審。一次 RPC 逾時就讓一個錢包停下來,而停多久居然不是我們能決定的?今天要處理的就是這件不合理的事。

今天的目標

我們今天要做的是讓 relayer 更好處理一筆卡住的交易:使用同樣的 nonce,但出更高的手續費,然後再次送出交易。節點肯不肯換掉舊那筆,規則寫在兩份官方文件裡:

  1. geth 的 --txpool.pricebump,預設一筆替換交易要比 mempool 裡的舊交易貴 10%,節點才會接受替代交易
  2. EIP-1559 的兩個出價欄位maxFeePerGasmaxPriorityFeePerGas)都要高過那個門檻,才蓋得掉前一筆交易

repo 中 internal/relayerExample_replaceStuck 用一個發送錢包送三筆付款,中間安排一筆送出時 RPC 逾時。輸出長這樣:

sync    0x90F7…b906  next 7    in-flight -    gap -
worker  nonce 7    pi_0001/settle   #1  sent         tx 0x0001
worker  nonce 8    pi_0002/settle   #1  retry        (send: rpc: timeout)
worker  nonce -    pi_0003/settle   #1  retry        (no slot: txseq: account has an unfilled gap at 8)
count   0x90F7…b906  next 9    in-flight -    gap 8
worker  nonce 8    pi_0002/settle   #2  replaced     tx 0x0002r (speed-up #8 fill, cap 33.000 gwei tip 2.200 gwei)
count   0x90F7…b906  next 9    in-flight -    gap -
worker  nonce 9    pi_0003/settle   #2  sent         tx 0x0003
  • 前四行是昨天的劇本:pi_0002 送出時逾時,8 變成一個跳號,pi_0003 連號都拿不到
  • 第六行是今天的新東西:pi_0002 卡夠久之後把 8 搶回來,出價從 30 gwei 加到 33 gwei,再送一次同一筆付款
  • 跳號補回來了,所以 pi_0003 拿到 9,正常送出

為什麼同一個 nonce 可以再送一次

替換交易機制會存在,完全是因鏈上基礎規則「同一個帳戶、同一個 nonce 的交易,最多只會有一筆進區塊」而產生。

由上圖可知,我們不需要知道舊那筆到底在不在 mempool 裡,也不需要知道它有沒有進區塊,反正結局都是「資產只會移動一次」,所以昨天那個「不知道」在這裡不再是問題。這也是為什麼替換送出去的可以就是原本那筆付款。

你可能會擔心舊那筆的手續費白付了,實際上不會,因為沒有進區塊的交易不收費,所以被換掉的那一筆從頭到尾沒有花到錢。真正的成本是最後上鏈的那一筆用了比較高的單價。

當然,沒有那麼好康,不是每條鏈都能這樣做:

卡住的時候能做什麼 出價怎麼加 要小心的地方
EVM 同 nonce 再送一筆 兩個欄位一起加,門檻由節點定 加價沒有天花板會把手續費燒乾
TON seqno 再送一則 external message 沒有出價競爭,訊息帶 valid_until,過期就被丟掉 幾乎沒有:合約只收剛好等於當前 seqno 的那一則
Solana 原封不動重送同一份簽好的交易 priority fee 在簽名前就固定,改了就是另一筆交易 為了加速重新組一筆,兩筆都可能上鏈
SUI 原封不動重送 gas price 綁 epoch 的 reference gas price 同一個 object 版本簽兩筆就是 equivocation,object 鎖到 epoch 結束

可以發現跟昨天狀況一樣:nonce 由發送方自己算的那兩條鏈(EVM、TON)才有替換交易這回事;讀鏈上值的那兩條(Solana、SUI)只能「重送同一筆」或「等它過期」。所以 ReplacingSender 在程式碼裡是獨立的一個介面,沒實作它的鏈照舊送審,救不了。

加價 10% 是節點的規矩

替換交易的出價要贏過舊交易多少,由節點的 mempool 規則決定。geth 預設要求新交易的兩個出價欄位都比舊的高 10%,比這低的話就會回一句 replacement transaction underpriced,等於白送一趟。

加價本身是整數運算,這裡有一個容易被坑的地方:

// bump 算 ceil(v * (100 + pct) / 100)。
func bump(v *big.Int, pct uint64) *big.Int {
	n := new(big.Int).Mul(v, new(big.Int).SetUint64(100+pct))
	q, r := new(big.Int).QuoRem(n, hundred, new(big.Int))
	if r.Sign() != 0 {
		q.Add(q, big.NewInt(1))
	}
	return q
}

節點算門檻用的是整數除法,我們這邊如果也無條件捨去,遇到除不盡的數字就會剛好差一個 wei 進不去。無條件進位多付的那一個 wei,比被退回來重送一趟便宜得多。repo 裡起價設 30 gwei,加價的序列是 33、36.3、39.93、43.923,小數一位都不能砍;真的接上鏈時,起價要改成從鏈上讀 base fee 與建議的 priority fee。

加價迴圈一定要有天花板。鏈上塞車的時候手續費沒有上限,而一筆付款值不值得用兩倍手續費推上去屬於商業決定,所以 Policy 把它做成參數:起價、加價幅度、Cap 的天花板、一筆 intent 最多廣播幾次。

直接把 nonce 消耗掉的方法

手續費加價救的是這筆交易,但有時候真的救不動的話,也可以選擇直接把 nonce 消耗掉。

「取消交易」是一筆從自己送給自己、金額為零的交易,用途是把那個 nonce 用掉。它比加速便宜,不是因為出價比較低(出價一樣要贏過舊交易),而是因為一筆純轉帳燒的 gas 是 21000,而一筆 ERC-20 transfer 是它的好幾倍。

取消交易送出去之後,我們不宣告這筆 intent failed。我們只知道它被廣播了,不知道它進區塊了沒,所以 intent 推到 needs_review、理由帶著取消交易的 hash,等有人看到鏈上發生什麼事之後再輸出結果。

決策樹上第一個問題對三種發送結果一視同仁,連「確定沒發送出去」也要等一輪。因為 StuckAfter 的意思一直是「這一個 nonce 已經沒有人在動它」而不是「鏈上那筆等很久了」;lease 過期只代表上一個 worker 沒回報,不代表它沒在送。

填補跳號的交易送出去之後

還有一個決定藏在 Counter 裡:填補跳號的交易一送出去,這個帳戶要不要馬上恢復發號?

我的話會選右邊。左邊那條路雖然在邏輯上比較乾淨,但它讓救援失去意義:我們花了一筆手續費把 nonce 搶回來,結果錢包還是停擺狀態。右邊的缺點是後面那些交易會先卡在 queued 區,而我們剛送出去的是出價最高的交易,理論上不用等它太久。

另一個缺點是在 confirming 那一格。以前一筆 intent 帶的 tx hash 就是唯一那次廣播,現在它是「我們最後一次送出去的那筆」,不保證是「進區塊的那筆」。所以我們之後每一次廣播都留一列紀錄(使用哪個 nonce、出多少價、拿到什麼 hash、三種發送結果的哪一種),對帳的時候要對的就是這幾列。

小結

今天主要在研究鏈上的交易怎麼救:替換就是在同一個 nonce 上再送一筆出價更高的交易,同號最多一筆進區塊,所以錢還是只動一次;加價幅度依據節點而定,整數運算要無條件進位,而且一定要有天花板;救不動這筆付款的時候改送一筆不動錢的交易把 nonce 清出來,救的對象從付款換成帳戶。四條鏈裡只有 EVM 與 TON 能替換,Solana 與 SUI 卡住的時候只能原封不動重送。

明天我打算把送出失敗的那些錯誤分類,畢竟現在不管是什麼原因失敗,job 都一律 Nack 回 queue 再試一次。

明天見。


上一篇
Day 10 | 交易序列化的四鏈對照:Nonce、Seqno、Blockhash、Object Version
系列文
Web3 支付工程筆記:企業級穩定幣結算系統與多鏈架構實戰11
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言