本日程式碼:repo tag day-14
Payment Intent 的轉移表從 Day 4 定案到現在,confirming 的三個出口(settled、退回 settling、needs_review)都寫著 listener,但這個 actor 到今天都還不存在:relayer 把 intent 推到 confirming 之後就沒有事可做了,帳上的 hold 也一直停在 pending。relayer 能說的只有「我送出去了、它進區塊了」,而在 finality 之前,區塊還是可以被換掉的。所以在宣告一筆付款完成之前得先回答一個問題:這條鏈上的「不可逆」到底是什麼意思?四條鏈給的答案沒有一個長得一樣。
我們今天要做 listener:針對停在 confirming 的 intent,去鏈上查詢那筆交易,等鏈說它不可逆之後才記 post、宣告 settled;被吐回來的退回 settling 交給 relayer。這邊可以參考的公開設計有兩個:
Observation 上的一個 Final 欄位,翻譯交給各鏈的 adapter,listener 不用知道自己在看哪條鏈(開發者喜聞樂見的抽象化xd)repo 中 internal/listener 的 Example_confirmThreeWays 用三筆停在 confirming 的付款示範三種結局。輸出長這樣:
watch head 100 all three included at 100
check pi_0001 wait tx 0x0001 (included at 100, 1 deep, not yet finalized)
check pi_0002 wait tx 0x0002 (included at 100, 1 deep, not yet finalized)
check pi_0003 wait tx 0x0003 (included at 100, 1 deep, not yet finalized)
watch head 164 finalized caught up; 0x0002 reorged out; 0x0003 moved nothing
check pi_0001 settled tx 0x0001 (finalized at 100, 65 deep)
check pi_0002 settling tx 0x0002 (not in any block for 5m0s; dropped or reorged out)
check pi_0003 needs_review tx 0x0003 (finalized at 100, 65 deep; no transfer carrying our ref, nothing moved)
check pi_0001 no-op tx 0x0001 (already settled)
#4 post 0xb02f8d29… by listener payer:0x7099…79C8 -100000000 merchant:0x3C44…93BC +100000000 tx 0x0001
pi_0001 settled v5 tx=0x0001
pi_0002 settling v5 tx=
pi_0003 needs_review v5 tx=0x0003
balance merchant USDC pending 200000000 posted 100000000
pi_0001 被 finalized 之後帳上多一筆 post,intent 推到 settled
pi_0002 被 reorg 吐回來,五分鐘後還是不在任何區塊裡,退回 settling
pi_0003 被 finalized、執行也成功,但交易裡沒有帶我們 ref 的轉帳,送審pi_0001 那 100 USDC,另外兩筆的 hold 還留在 pending| 鏈 | 鏈自己宣稱的最終態 | 要等多久 | listener 要確認的東西 |
|---|---|---|---|
| EVM | finalized block tag:PoS 每 32 個 slot 一個 epoch,連續兩個 epoch 有三分之二以上的 stake 投過票的區塊 |
十幾分鐘(Circle 的 Standard 等 65 個區塊) | 交易所在的區塊高度有沒有小於等於 finalized 的高度 |
| Solana | finalized commitment(maximum lockout);confirmed 只代表超過三分之二的 stake 投過票 |
32 個 slot 左右(Circle 給的是約 25 秒) | 交易的 confirmationStatus 到了 finalized 沒有 |
| TON | 「Once a transaction from a shardchain appears in a masterchain block, it becomes irreversible」 | 一個 masterchain block,約 1 秒 | 交易所在的 shard block 被哪個 masterchain block 引用了 |
| SUI | 「Inclusion in a certified checkpoint is itself proof of finality」 | 400 到 700 毫秒 | 交易身上有沒有 checkpoint 編號 |
可以發現沒有一個叫「N 個 confirmations」。數區塊深度是 PoW 留下來的習慣,四條裡只有 EVM 還能把它當 finalized 之前的代替品;另外三條的「深度」不是同一個意思:Solana 沒被 finalized 的 slot 會整個被丟掉,TON 與 SUI 的不可逆是一步到位的,被 masterchain 引用了就是了、進 checkpoint 就是了,後面再壓多少個都不會更不可逆。所以 Observation 上的 Height 與 Head 只拿來比大小,各自代表什麼由 adapter 翻譯,而 listener 真正看的是 Final 那一欄。
既然 EVM 上還是有人數深度,policy 就得同時容納兩個條件:
預設是右邊,四條鏈都等鏈自己的 marker:
func Defaults() map[string]Policy {
return map[string]Policy{
"evm": {Marker: "finalized", RequireMarker: true, LostAfter: 5 * time.Minute},
"solana": {Marker: "finalized", RequireMarker: true, LostAfter: 2 * time.Minute},
"ton": {Marker: "masterchain", RequireMarker: true, LostAfter: 2 * time.Minute},
"sui": {Marker: "checkpoint", RequireMarker: true, LostAfter: 2 * time.Minute},
}
}
Confirmations 是疊在上面的第二個條件,兩個都開的話就兩個都要過;把 marker 關掉、只數深度也可以,那就是 Circle 的 Fast Transfer,意思是「我願意承擔 reorg 的風險換時間」。
Judge 是一棵四個問題的決策樹,順序是刻意排的:
「執行成功」的檢測擺在最後一個非常重要。一筆 revert 的交易在 finalized 之前,跟一筆成功的交易一樣可能被 reorg 換掉,換掉之後同一筆交易可能在另一個區塊裡成功。所以「鏈上說失敗」跟「鏈上說成功」都要等到不可逆才能信;早一步把它送去人工介入,operator 看到的會是一個還可能翻盤的結果。
// 理由裡寫的是「憑什麼算不可逆」:等 marker 的寫 marker,只數深度的寫深度。人看理由就知道這條鏈用的是哪一個判斷標準。
where := fmt.Sprintf("%d confirmations at %d", depth, obs.Height)
if p.RequireMarker {
where = fmt.Sprintf("%s at %d, %d deep", p.Marker, obs.Height, depth)
}
if !obs.Succeeded {
return Verdict{Kind: KindFailed, Reason: where + " but the execution failed; gas burned, nothing moved"}
}
return Verdict{Kind: KindFinal, Reason: where}
至於 lost 那條路:一筆交易還在 mempool 裡、被 reorg 吐回來、或已經被節點丟掉,對 listener 來說是同一件事,它不在任何區塊裡,listener 也不去分。超過 LostAfter 就走轉移表上唯一的回頭路退回 settling,tx hash 清掉;接下來要在同一個 nonce 上再送一筆、還是再等,relayer 讀 Broadcasts 自己決定,listener 不替它做這個決定。
Judge 回 final 之後,listener 才問第二個問題:
這兩個問題分開問,是因為答案的來源不同:不可逆是鏈對「這筆交易」的承諾,錢有沒有動要看交易裡那筆帶著我們 ref 的轉帳;一筆 finalized 的交易可以一毛錢都沒動,Day 2 那種回傳 false 的 token 就是這樣,finality 過了只代表這個結果不會再變。三種對不上的情況 listener 都不敢自己判,判錯的兩邊都是錢:宣告 settled 是替商家記一筆沒收到的錢,宣告 failed 是把一筆可能已經到帳的付款當成沒付。
對得上的那條路順序是帳先動、狀態後走,跟 relayer 記 hold 同一個方向:post 對同 ID 是 no-op、settled 重放也是 no-op,死在兩步中間重來一次沒關係;反過來先推 settled 的話,settled 是 terminal,那筆 hold 會永遠掛在 pending 上。post 的時間用 intent 進 confirming 的那一刻而不是 listener 自己的時鐘,同一筆 intent 不管哪個 listener、第幾次來算,算出來的 post 都一樣,journal 才會把第二次當成重放、不會回 ErrConflict。
我們今天討論了 listener 怎麼把「進區塊」變成「結束」:四條鏈的不可逆各是一個不同的東西(finalized tag、finalized commitment、masterchain block、checkpoint),policy 預設等鏈自己的 marker,深度是拿來換時間的第二個條件;失敗跟成功用同一套規則判,都要等到不可逆才信;不可逆之後還要看錢有沒有照 ref 動,對不上的一律送審,對得上才記 post、推 settled。
今天 listener 只看「我送出去的那筆」,明天再來實作真正能夠看著鏈下 intent 然後對齊鏈上資產的對帳引擎。
明天見。