iT邦幫忙

2026 iThome 鐵人賽

DAY 15
0

本日程式碼:repo tag day-15

relayer 的流程裡有一個我們一直接受下來的問題:交易送出去了、把 tx hash 寫回 intent 之前程序死掉。鏈上那筆交易照樣進區塊、照樣被 finalized、照樣帶著我們的 ref,鏈下那筆 intent 卻停在 settling、身上沒有 hash。昨天的 listener 幫不上忙,因為它是拿 intent 身上的 tx hash 去問鏈的,而這筆 intent 沒有 hash 可問。反過來的情況更沒有人管:鏈上一筆帶著我們 ref 的轉帳,只要不是掛在某筆 intent 的 hash 上,今天之前沒有任何元件會看它一眼。

今天的目標

我們今天要做 Reconciliation Engine(對帳引擎):把鏈上一段已經不可逆的區塊裡所有動過的錢撈出來,跟鏈下的 intent 與帳本對一遍。對得上的算數,缺證據的補證據,對不上的列成一筆 finding 等待人工介入。對帳引擎的公開文獻不多(大家都在做,很少人細寫),所以本設計為本系列從零設計,出發點是複式記帳的原理,加上一些大廠的經驗談:

  1. 根據 Modern Treasury 的 Expected Payments:「Create an Expected Payment to represent and reconcile a payment that you expect to receive or send」,我們的 intent 就是他們說的 expected payment;他們拿金額範圍、日期範圍與自訂識別碼當比對條件,但我們有一把更準的 payment ref 嘿嘿
  2. Modern Treasury 對於 payment intent 的例外處理:「When Modern Treasury cannot automatically match an Expected Payment with a Transaction, users can step in to manually review and reconcile the items」,所以可知對不上的不硬對,留給人工介入;我們的 finding 照這條做
  3. Stripe 的 payout reconciliation report 是按日算好的批次報表,資料隔天才齊;也就是說,對帳可以是批次的(對一段已經關帳的區間來說,延遲沒關係、但錯就 GG 了)

repo 中 internal/reconExample_reconcileWindow 用五筆停在不同地方的付款與鏈上七筆轉帳跑一次對帳。輸出長這樣:

sweep   pi_0002  settling     already queued
sweep   pi_0003  authorized   enqueued pi_0003/settle
sweep   pi_0004  confirming   settled (finalized at 100, 65 deep)
window  blocks 1..164 finalized  3 matched, 4 findings
 
match   0x0001 pi_0001  settled, post matches the chain
match   0x0002 pi_0002  settling -> confirming -> settled (finalized at 100, 65 deep)
match   0x0004 pi_0004  settled, post matches the chain
finding unexpected   tx 0x0005 ref 0x3d54643e… (pi_0005) is failed, yet the money moved
finding unknown_ref  tx 0x0006 ref 0x908f939d… matches no intent
finding unreferenced tx 0x0007 100000000 to 0x3C44…93BC without a ref
finding paid_twice   tx 0x0008 ref 0xb02f8d29… (pi_0001) already settled on tx 0x0001
 
cursor  0 -> 164
  • 前三行是鏈下掃描:pi_0003 的 job 不見了,補丟一份;pi_0004 停在 confirming,交給 listener 收成 settled
  • 0x0002 就是開頭那個問題:pi_0002 停在 settling 沒有 hash,對帳引擎拿鏈上這筆補上證據、交給 listener 走完
  • 四筆 finding 各是一種對不上:付款已宣告 failed 錢卻動了、ref 沒有對應的 intent、沒帶 ref、同一個 ref 動了第二次錢
  • 最後 cursor 推到 finalized 的 164;再跑一次,同一段 window 不會再對第二遍

對帳引擎不是第二個 listener

listener 與對帳引擎讀的是同一條鏈,但問的問題不一樣:listener 拿一筆 intent 的 tx hash 問「這筆交易結束了沒」,對帳引擎拿一段 block height 問「這段時間所有動過的錢,每一筆是否都存在對應資料」。前者由鏈下出發、一次一筆;後者由鏈上出發、一次一段,做的其實是鏈上與鏈下之間的 outer join:

這其實是複式記帳的精神往外推一層:把鏈上與鏈下當成兩本帳,同一個 ref 就該在兩邊各出現剛好一次。少一邊是有人沒做完工作,多一邊是錢動了卻沒有人在等它,兩種都不該安靜地過。

比對的金額也挑過:settled 的付款對的是那筆 post 的 merchant 腿,不是 intent 上的請款金額。請款與實收本來就可以不同,拿請款金額對鏈,每一顆抽稅的 token 都會變成假警報。

只對已經不可逆的區塊

window 的上界要選一個 block height,這裡有兩條路:

我選右邊,理由跟昨天判交易失敗也要等到不可逆是同一條:finding 是要叫人來看的,叫人之前得確定它不會自己消失。還沒 finalized 的那一段本來就有 listener 與 relayer 在顧,對帳引擎不用搶快。實作上這條路也便宜:EVM 的 eth_getLogs 直接收 fromBlocktoBlock,而且兩個欄位都認得 finalized 這個 tag,window 就是一次 RPC 呼叫的參數。

cursor 只在整段 window 對完之後才前進,中間任何一步出錯,cursor 不動,下一次整段重來。重來是安全的,因為它做的每一件事都是冪等的(Enqueue 同 ID、post 同 ID、重放的 transition 都是 no-op)。同一段 window 對兩次,得到同一份 finding,帳本不多任何紀錄。

拿著 ref 對回 intent

window 裡的每一筆轉帳都走同一棵決策樹,而 finding 就是樹上那五片對不上的葉子:

可以從圖得知,整棵樹只有一條路會動到 intent,就是補證據那條。settling 的情況最典型,也就是開頭那個問題:

	case intent.StateSettling:
		// relayer 送出去了、寫回 confirming 之前死掉:鏈上有交易、intent 沒有 hash。這是對帳引擎補證據的正宗用途,
		// 轉移表上 settling -> confirming 本來就寫著 listener。補完交給 listener 判,不自己宣告 settled。
		ok, err := e.push(ctx, it.ID, intent.Request{To: intent.StateConfirming, By: intent.ActorListener, TxHash: t.TxHash, At: e.now()})
		if err != nil || !ok {
			return e.raced(t, it.ID, rep, err)
		}
		return e.check(ctx, t, it.ID, "settling -> confirming -> ", rep)

對帳引擎在轉移表上用的 actor 是 listener,而這件事轉移表定案那天就寫好了:ActorListener 的註解是「盯著鏈看的元件(chain listener 與之後的對帳引擎都算)」。它的權限也就到補證據為止,宣告 settledfailed 的判斷還是 listener 與 operator 的,因為判斷只該住在一個地方;一個會自己下判決的對帳引擎就是第二個 listener,帶著自己的 bug。反過來,補證據的條件比叫人嚴:ref 只是寫進交易的 32 bytes,誰都抄得走,token、付款人、收款人有一個對不上,那筆轉帳就只是 finding,不是證據。

confirming 的補證據來自替換交易:intent 記著最後一次送出去的 hash,進區塊的卻可能是同號的另一筆。listener 不能拿另一筆 hash 宣告 settled(ErrEvidenceMismatch 擋著),所以對帳引擎先走唯一的回頭路把舊 hash 清掉,再帶著鏈上那筆重新進 confirming,兩步都留在 History 上。

至於五片葉子裡最刺眼的 paid_twice:EVM 上同號最多一筆進區塊,但 Solana 上原封不動重送的兩筆是不同的交易,兩筆都可能上鏈。對帳引擎是這件事的最後一道偵測:先進區塊的算數,第二筆列出來;帳本不會跟著多記,一筆 hold 只能收尾一次。而 unreferenced 是交易所的老問題:付款人繞過結算合約、直接對 token 合約 transfer,鏈上沒有地方放 ref。Kraken 對沒帶 memo 的入金寫得直白:「Omitting or entering an incorrect tag/memo can prevent your deposit from being credited to your account」,不自動入帳、人工找回。我們也一樣:這筆錢沒有 hold 可收,什麼都不替它記。其餘幾片葉子的細節在 repo internal/recon 的註解裡。

順便掃一遍沒有人在驅動的 intent

鏈上對完了,鏈下還有事情要忙:有些 intent 對不出問題,純粹是沒有人在驅動它。每一次 Run 的開頭先掃一遍:

  • 掉了的 job 從 intent store 補回來:API 寫完 intent、丟 job 之前死掉,那份工作就掉了。Brandur 的 Transactionally Staged Job Drains 用一張 staged jobs 表加一個 enqueuer 解這件事(「Jobs are only removed after they're successfully transmitted to the queue」)。我們不用另開表,intent store 本身就是那張表,狀態停在 authorizedsettling,就代表這份工作該在 queue 上。

  • 停在 DLQ 裡的不碰:那份 job 被放棄是有理由的,再丟一份回去只是把 poison 的迴圈拉長;能放它回去的只有人工介入。

  • confirming 的交給 listener.Check:昨天留下的「誰把 confirming 的 intent 交給 listener」,答案就是這裡:對帳引擎每一次 Run 都交一次,listener 自己不用排程。

(題外話:cursor 與 finding 今天都只在記憶體裡,資料庫版要把兩者寫進同一個 transaction;另外「settled 了但鏈上從來沒出現那筆交易」這個方向今天沒查,它需要記住每一筆 post 被哪個 window 對到過,之後再來補。)

小結

今天我們討論的是鏈上鏈下怎麼對齊:對帳引擎把一段 finalized 的區塊當成另一本帳,拿 ref 跟 intent store 與 journal 做 outer join,對得上的兩邊都不動,缺證據的補上 hash 交給 listener,對不上的收成五種 finding。它的權限只有補證據與叫人,判斷還是住在原來的地方。cursor 只走 finalized 過的 block height,所以它列出來的 finding 不會被 reorg 收回去。到這裡當初我們想像中的鏈下功能就全部到齊了。

明天開始往鏈上走,先來討論結算合約的整體形狀:pull 與 push 兩種支付流各長什麼樣。

明天見。


上一篇
Day 14 | Chain Listener 與 Finality Policy:四條鏈的「不可逆」分別是什麼意思
下一篇
Day 16 | 結算合約概觀:Pull vs Push 支付流
系列文
Web3 支付工程筆記:企業級穩定幣結算系統與多鏈架構實戰16
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言