本日程式碼:repo tag day-11
昨天最後留下的問題是:一筆交易送出去、沒想到 RPC 呼叫逾時,我們不知道節點有沒有接收到它,所以那個 nonce 只能當成用掉;結果序列上留了一個沒交代的 nonce,整個錢包從此停止發號。昨天能做的只有等,等 Sync 看到鏈上走過那一個 nonce,或是等時間到 StuckAfter 之後把 intent 送審。一次 RPC 逾時就讓一個錢包停下來,而停多久居然不是我們能決定的?今天要處理的就是這件不合理的事。
我們今天要做的是讓 relayer 更好處理一筆卡住的交易:使用同樣的 nonce,但出更高的手續費,然後再次送出交易。節點肯不肯換掉舊那筆,規則寫在兩份官方文件裡:
--txpool.pricebump,預設一筆替換交易要比 mempool 裡的舊交易貴 10%,節點才會接受替代交易maxFeePerGas 與 maxPriorityFeePerGas)都要高過那個門檻,才蓋得掉前一筆交易repo 中 internal/relayer 的 Example_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 的交易,最多只會有一筆進區塊」而產生。
由上圖可知,我們不需要知道舊那筆到底在不在 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 在程式碼裡是獨立的一個介面,沒實作它的鏈照舊送審,救不了。
替換交易的出價要贏過舊交易多少,由節點的 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 用掉。它比加速便宜,不是因為出價比較低(出價一樣要贏過舊交易),而是因為一筆純轉帳燒的 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 再試一次。
明天見。