iT邦幫忙

2026 iThome 鐵人賽

DAY 14
0
Software Development

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

Day 14 | Chain Listener 與 Finality Policy:四條鏈的「不可逆」分別是什麼意思

  • 分享至 

  • xImage
  •  

本日程式碼:repo tag day-14

Payment Intent 的轉移表從 Day 4 定案到現在,confirming 的三個出口(settled、退回 settlingneeds_review)都寫著 listener,但這個 actor 到今天都還不存在:relayer 把 intent 推到 confirming 之後就沒有事可做了,帳上的 hold 也一直停在 pending。relayer 能說的只有「我送出去了、它進區塊了」,而在 finality 之前,區塊還是可以被換掉的。所以在宣告一筆付款完成之前得先回答一個問題:這條鏈上的「不可逆」到底是什麼意思?四條鏈給的答案沒有一個長得一樣。

今天的目標

我們今天要做 listener:針對停在 confirming 的 intent,去鏈上查詢那筆交易,等鏈說它不可逆之後才記 post、宣告 settled;被吐回來的退回 settling 交給 relayer。這邊可以參考的公開設計有兩個:

  1. 四條鏈各自的官方文件對「不可逆」的定義(下一 section 的表),我們實作上把它們收成 Observation 上的一個 Final 欄位,翻譯交給各鏈的 adapter,listener 不用知道自己在看哪條鏈(開發者喜聞樂見的抽象化xd)
  2. Circle 的 CCTP 公開的 finality thresholds:同一條 Ethereum 分成 Fast Transfer(2 個區塊、約 20 秒)與 Standard Transfer(約 65 個區塊、15 到 19 分鐘)兩級,所以我們的 policy 也留兩個條件,等鏈自己的 marker 是預設,數深度是用風險換時間的捷徑

repo 中 internal/listenerExample_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
  • 第一段三筆都在等:進區塊只有 1 個區塊深,finalized 還沒追上來
  • pi_0001 被 finalized 之後帳上多一筆 post,intent 推到 settled
  • pi_0002 被 reorg 吐回來,五分鐘後還是不在任何區塊裡,退回 settling
  • pi_0003 被 finalized、執行也成功,但交易裡沒有帶我們 ref 的轉帳,送審
  • 最後一行是 merchant 的餘額:posted 只多了 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 上的 HeightHead 只拿來比大小,各自代表什麼由 adapter 翻譯,而 listener 真正看的是 Final 那一欄。

policy 只留兩個條件

既然 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 不替它做這個決定。

finality 過了不等於錢動了

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 然後對齊鏈上資產的對帳引擎。

明天見。


上一篇
Day 13 | DLQ Redrive 設計:不重複打款的保證,與人工介入的邊界
下一篇
Day 15 | 鏈上鏈下對齊:Reconciliation Engine 實作
系列文
Web3 支付工程筆記:企業級穩定幣結算系統與多鏈架構實戰16
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言