本日程式碼:repo tag day-10
昨天 pool 建起來之後,現在 N 個 worker 各領各的 job,誰先誰後都無所謂;但那 N 筆交易要上鏈的時候都是從同一個錢包出去的,而每條鏈都需要用某種 unique 的數字,把同一個帳戶送出的交易排序或獨立開來:沒錯,相信大家老熟了,在 EVM 上那個數字叫 nonce,兩筆以上的交易帶著同一個 nonce 的話只有一筆會留在鏈上。今天要處理的是這些交易順序由誰來負責安排,還有有些交易得到號碼了、卻沒發出去會怎樣。
我們今天要做一個發號的元件,讓 worker 在開始動作之前先拿到「這筆交易拿到了哪個號碼」,再把「拿了號碼卻沒送出去」定義清楚。
四條鏈排隊的方式差很多,所以我們要先把各自的官方文件讀一遍(為了方便,後續我們都用 nonce 統稱那個號碼):
eth_getTransactionCount)(ObjectId, SequenceNumber) 被兩筆還沒 finalize 的交易同時用到就是 equivocation,那個 object 會被鎖到這個 epoch 結束repo 中 internal/relayer 的 Example_nonceGap 用一個發送錢包送五筆付款,中間安排一筆確定沒出門、一筆不知道有沒有出門。前半段輸出長這樣:
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: relayer: transaction was not sent: signing failed)
worker nonce 8 pi_0003/settle #1 sent tx 0x0003
worker nonce 9 pi_0004/settle #1 retry (send: rpc: timeout)
worker nonce - pi_0005/settle #1 retry (no slot: txseq: account has an unfilled gap at 9)
count 0x90F7…b906 next 10 in-flight - gap 9
pi_0002 簽名失敗,可以透過 Sender 確定沒有送出這筆交易,所以 8 退回去,然後 pi_0003 拿到同一個 nonce 8pi_0004 送出時 RPC 逾時,沒有人知道那筆交易有沒有進節點,所以 9 只好當成用掉了(但其實還沒)pi_0005 因為實際上 nonce 有 gap(可能根本沒送出),所以連 nonce 都拿不到,原封不動返回 queue(intent 還在 authorized、帳上沒有 hold)| 鏈 | 作為所謂 nonce 的數字 | 誰算出來的 | 填錯的下場 | 同帳戶能不能同時送 |
|---|---|---|---|---|
| EVM | nonce,每個帳戶一個遞增整數 | 發送方自己算 | 太小直接被拒;太大排進節點的 queued 區,等前面的空位被填掉 | 可以,但跳號的都要等待 |
| Solana | recent blockhash,不是序號 | 從鏈上讀最近的 block | 過期就失效,不會卡到別人 | 可以,天生不用排 |
| TON | seqno,錢包合約裡的計數器 | 從合約讀 | 不等於當前值就不收,不會卡住後面 | 不行,一個錢包一次一筆 |
| SUI | owned object 的版本 | 從鏈上讀那個 object | 同一版本被兩筆同時用是 equivocation,object 鎖到 epoch 結束 | 看你把 gas coin 切成幾個 object |
總歸來說,我們只需要考慮 nonce 是誰算出來的:
故我們介面上應分成兩種 Sender:
OrderedSender 的鏈,worker 會先幫它取號再呼叫 SendAt
Send,relayer 完全不介入最省事的做法是每次送之前問一次 eth_getTransactionCount(addr, "pending"),鏈上回什麼就填什麼。單執行緒下這樣沒問題,但我們的 worker 有 N 個。
在同一個時間點,兩個 worker 在鏈上得到的會是同一個數字,所以當兩筆不同的交易帶著同一個 nonce 來到同個節點的話,節點最多只留下其中一筆,而被擠掉的那一筆不會回任何錯誤、它會直接消失不見;但這時我們鏈下的 intent 已經是 confirming、而且帶著一個永遠不會上鏈的 tx hash。故 nonce 只能有一個發放者,而那個發放者只能是 relayer,因為只有它知道現在有幾個 worker 在操作同一個錢包。
此外,計數器的即時數值在鏈上不在鏈下,所以一旦後端重啟、或有別人用這錢包在其他地方發送交易,我們記的數字就會是錯誤的。因此,Counter 要實作一個 Sync 來拿鏈上即時的值蓋掉鏈下記錄的值。
nonce 真正被拿走之前都好說,但被某筆交易拿到之後就不一樣了:名額有 defer 就還得掉,但 nonce 能不能還就要看那筆交易到底有沒有出門。
const (
// SentYes:節點收下了。nonce 用掉,計數器往前。
SentYes Sent = "sent"
// SentNo:確定沒發送出去(簽名失敗、參數組不出來、連線根本沒建立)。nonce 退回去給下一筆用。
SentNo Sent = "not-sent"
// SentUnknown:不知道。nonce 當成用掉了,序列上留一個洞,這個帳戶暫停發號等人來對帳。
// 退回去重用會撞到那筆可能正躺在 mempool 裡的交易。
SentUnknown Sent = "unknown"
)
發號器與兩個實作都在 internal/txseq,完整程式碼在 repo 的 day-10 tag。三種發送結果各自對計數器做一件事:
預設是「不知道」,要 Sender 主動包一個 ErrNotSent 才算「確定沒出門」。預設偏保守是因為兩邊猜錯的代價不對稱:把沒出門的當成不知道,最壞是留一個洞、這個帳戶停下來等人來看;但把出門了的當成沒出門,下一筆會帶著同一個號,去撞自己那筆還躺在 mempool 的交易(保證其中一筆失敗)。
此外,跳號問題每條鏈的成本也不同:TON 的錢包合約不收就算了,跳一個 seqno 沒有什麼成本;但 EVM 跳號會把這個帳戶後面所有交易都掛在 queued 區,一筆都進不了區塊。所以 Counter 一遇到洞就把整個帳戶停下來,後面的 job 在取號那一關就被擋掉,原封不動回 queue。這是刻意的:擋在副作用之前,那批 intent 就停在 authorized,不會一路變成 settling、加了 hold,最後全部送審。
於是一筆下落不明的交易會在鏈下卡一筆 settling 的 intent、在鏈上躺一個沒人認領的交易。但我們現在有辦法解決了:鏈下的等到 StuckAfter 就送審,然後鏈上的只能等那筆交易上鏈直到 Sync 看到。
應該有資深的區塊鏈開發者會問:「鏈上那筆卡住的,拿同一個 nonce 再送一筆交易把它蓋掉不就好了?」對,但這部分我們應該明天才會細說。
Counter 現在一個帳戶只發一個 nonce,交易終結後才發下一個。但實務上的 relayer 會同時操作好幾個 nonce、吞吐高很多,只要中間有一個沒發送成功,後面那些就全部在等它,而把洞填掉需要另一套手段。
所以提高吞吐不應該是「提升單個錢包的非同步交易數量」,而是「多開幾個發送錢包」。
圖上有三個數字,是三個不同的限制:worker 數是同時有幾份 job 在手上、名額數是同時有幾筆在往 RPC 送、錢包數是同時有幾筆能真的並行上鏈。直覺上這三個應該是同一個數字,但實際上「同時有幾筆能真的並行上鏈」常常是最小的那個(到了 Solana 與 SUI 這個數字甚至直接消失:Solana 的 blockhash 每筆各自讀,SUI 只要 gas coin 切得開,同一個地址要並行幾筆都可以)。
今天在研究錢包如何併發送出交易:nonce 只有 relayer 這個發放點;拿不到 nonce 就原封不動回 queue;發送結果分成「已送出」、「確定沒發送」、「不知道」三種,只有第二種退得回去,第三種會跳號、整個帳戶停止發號。四條鏈裡只有 EVM 與 TON 需要上述這套東西,因為 Solana 與 SUI 把這個數字換成了「送出當下從鏈上讀一個值」。
明天我打算讓 relayer 有辦法處理卡住的交易。
明天見。